Update New Features

This commit is contained in:
Koncept Kit
2025-12-10 17:52:47 +07:00
parent 1f27c3224b
commit 36017e8693
39 changed files with 4977 additions and 196 deletions

View File

@@ -45,8 +45,10 @@
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.507.0",
"moment": "^2.30.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-big-calendar": "^1.19.4",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.2",

BIN
public/hero-loaf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/icon-active.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/icon-meet-greet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/icon-socials.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -2,8 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#664fa3" />
<meta name="description" content="LOAF - Lesbian Organization of Atlanta Family" />
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">

BIN
public/loaf-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
public/shooting-star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/tagline-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -13,6 +13,7 @@ import Profile from './pages/Profile';
import Events from './pages/Events';
import EventDetails from './pages/EventDetails';
import Plans from './pages/Plans';
import BecomeMember from './pages/BecomeMember';
import PaymentSuccess from './pages/PaymentSuccess';
import PaymentCancel from './pages/PaymentCancel';
import AdminDashboard from './pages/admin/AdminDashboard';
@@ -26,6 +27,17 @@ import AdminPlans from './pages/admin/AdminPlans';
import AdminLayout from './layouts/AdminLayout';
import { AuthProvider, useAuth } from './context/AuthContext';
import MemberRoute from './components/MemberRoute';
import MemberCalendar from './pages/members/MemberCalendar';
import MemberProfile from './pages/members/MemberProfile';
import MembersDirectory from './pages/members/MembersDirectory';
import EventGallery from './pages/members/EventGallery';
import NewsletterArchive from './pages/members/NewsletterArchive';
import Financials from './pages/members/Financials';
import Bylaws from './pages/members/Bylaws';
import AdminGallery from './pages/admin/AdminGallery';
import AdminNewsletters from './pages/admin/AdminNewsletters';
import AdminFinancials from './pages/admin/AdminFinancials';
import AdminBylaws from './pages/admin/AdminBylaws';
const PrivateRoute = ({ children, adminOnly = false }) => {
const { user, loading } = useAuth();
@@ -62,6 +74,7 @@ function App() {
</PrivateRoute>
} />
<Route path="/plans" element={<Plans />} />
<Route path="/become-a-member" element={<BecomeMember />} />
<Route path="/payment-success" element={<PaymentSuccess />} />
<Route path="/payment-cancel" element={<PaymentCancel />} />
@@ -85,7 +98,49 @@ function App() {
<EventDetails />
</MemberRoute>
} />
{/* Members Only Routes */}
<Route path="/members/calendar" element={
<MemberRoute>
<MemberCalendar />
</MemberRoute>
} />
<Route path="/members/profile" element={
<MemberRoute>
<MemberProfile />
</MemberRoute>
} />
<Route path="/members/directory" element={
<MemberRoute>
<MembersDirectory />
</MemberRoute>
} />
<Route path="/members/gallery" element={
<MemberRoute>
<EventGallery />
</MemberRoute>
} />
<Route path="/members/gallery/:eventId" element={
<MemberRoute>
<EventGallery />
</MemberRoute>
} />
<Route path="/members/newsletters" element={
<MemberRoute>
<NewsletterArchive />
</MemberRoute>
} />
<Route path="/members/financials" element={
<MemberRoute>
<Financials />
</MemberRoute>
} />
<Route path="/members/bylaws" element={
<MemberRoute>
<Bylaws />
</MemberRoute>
} />
<Route path="/admin" element={
<PrivateRoute adminOnly>
<AdminLayout>
@@ -142,6 +197,34 @@ function App() {
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/gallery" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminGallery />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/newsletters" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminNewsletters />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/financials" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminFinancials />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/bylaws" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminBylaws />
</AdminLayout>
</PrivateRoute>
} />
</Routes>
<Toaster position="top-right" />
</BrowserRouter>

View File

@@ -0,0 +1,216 @@
import React, { useState } from 'react';
import { Calendar, ChevronDown, Download, RefreshCw } from 'lucide-react';
import { Button } from './ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
/**
* AddToCalendarButton Component
* Universal calendar export button with support for:
* - Google Calendar (web link)
* - Microsoft Outlook (web link)
* - Apple Calendar / Outlook Desktop (webcal:// subscription)
* - Download .ics file (universal import)
* - Subscribe to personal feed (auto-sync)
*/
export default function AddToCalendarButton({
event = null, // Single event object { id, title, description, start_at, end_at, location }
showSubscribe = false, // Show "Subscribe to My Events" option
variant = "default", // Button variant: default, outline, ghost
size = "default" // Button size: default, sm, lg
}) {
const [isOpen, setIsOpen] = useState(false);
const backendUrl = process.env.REACT_APP_BACKEND_URL || 'http://localhost:8000';
// Format datetime for Google Calendar (YYYYMMDDTHHMMSSZ)
const formatGoogleDate = (dateString) => {
const date = new Date(dateString);
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
};
// Generate Google Calendar URL
const getGoogleCalendarUrl = () => {
if (!event) return null;
const params = new URLSearchParams({
action: 'TEMPLATE',
text: event.title,
dates: `${formatGoogleDate(event.start_at)}/${formatGoogleDate(event.end_at)}`,
details: event.description || '',
location: event.location || '',
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
};
// Generate Microsoft Outlook Web URL
const getOutlookWebUrl = () => {
if (!event) return null;
const startDate = new Date(event.start_at).toISOString();
const endDate = new Date(event.end_at).toISOString();
const params = new URLSearchParams({
path: '/calendar/action/compose',
rru: 'addevent',
subject: event.title,
startdt: startDate,
enddt: endDate,
body: event.description || '',
location: event.location || '',
});
return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
};
// Get .ics download URL
const getIcsDownloadUrl = () => {
if (!event) return null;
return `${backendUrl}/api/events/${event.id}/download.ics`;
};
// Get webcal:// subscription URL (for Apple Calendar / Outlook Desktop)
const getWebcalUrl = () => {
// Convert http:// to webcal://
const webcalUrl = backendUrl.replace(/^https?:\/\//, 'webcal://');
return `${webcalUrl}/api/calendars/subscribe.ics`;
};
// Get all events download URL
const getAllEventsUrl = () => {
return `${backendUrl}/api/calendars/all-events.ics`;
};
// Handle calendar action
const handleCalendarAction = (action) => {
setIsOpen(false);
switch (action) {
case 'google':
window.open(getGoogleCalendarUrl(), '_blank');
break;
case 'outlook':
window.open(getOutlookWebUrl(), '_blank');
break;
case 'apple':
window.location.href = getWebcalUrl();
break;
case 'download':
window.open(getIcsDownloadUrl(), '_blank');
break;
case 'subscribe':
window.location.href = getWebcalUrl();
break;
case 'all-events':
window.open(getAllEventsUrl(), '_blank');
break;
default:
break;
}
};
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size} className="gap-2">
<Calendar className="h-4 w-4" />
Add to Calendar
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{event && (
<>
{/* Single Event Export Options */}
<div className="px-2 py-1.5 text-sm font-semibold text-[#422268]">
Add This Event
</div>
<DropdownMenuItem
onClick={() => handleCalendarAction('google')}
className="cursor-pointer"
>
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
Google Calendar
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleCalendarAction('outlook')}
className="cursor-pointer"
>
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 2h14v20H7V2zm7 11c0 2.761-2.239 5-5 5H2c-.552 0-1-.448-1-1s.448-1 1-1h7c1.657 0 3-1.343 3-3V9c0-1.657-1.343-3-3-3H2c-.552 0-1-.448-1-1s.448-1 1-1h7c2.761 0 5 2.239 5 5v4z"/>
</svg>
Outlook Web
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleCalendarAction('apple')}
className="cursor-pointer"
>
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>
Apple Calendar
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleCalendarAction('download')}
className="cursor-pointer"
>
<Download className="h-4 w-4 mr-2" />
Download .ics File
</DropdownMenuItem>
{showSubscribe && <DropdownMenuSeparator />}
</>
)}
{showSubscribe && (
<>
{/* Subscription Options */}
<div className="px-2 py-1.5 text-sm font-semibold text-[#422268]">
Calendar Feeds
</div>
<DropdownMenuItem
onClick={() => handleCalendarAction('subscribe')}
className="cursor-pointer"
>
<RefreshCw className="h-4 w-4 mr-2" />
Subscribe to My Events
<div className="text-xs text-[#664fa3] mt-0.5">
Auto-syncs your RSVP'd events
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleCalendarAction('all-events')}
className="cursor-pointer"
>
<Download className="h-4 w-4 mr-2" />
Download All Events
<div className="text-xs text-[#664fa3] mt-0.5">
One-time import of all upcoming events
</div>
</DropdownMenuItem>
</>
)}
{!event && !showSubscribe && (
<div className="px-2 py-6 text-center text-sm text-[#664fa3]">
No event selected
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -15,7 +15,12 @@ import {
ChevronLeft,
ChevronRight,
LogOut,
Menu
Menu,
Image,
FileText,
DollarSign,
Scale,
HardDrive
} from 'lucide-react';
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
@@ -23,6 +28,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
const navigate = useNavigate();
const { user, logout } = useAuth();
const [pendingCount, setPendingCount] = useState(0);
const [storageUsed, setStorageUsed] = useState(0);
const [storageLimit, setStorageLimit] = useState(0);
const [storagePercentage, setStoragePercentage] = useState(0);
// Fetch pending approvals count
useEffect(() => {
@@ -44,6 +52,33 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
return () => clearInterval(interval);
}, []);
// Fetch storage usage
useEffect(() => {
const fetchStorageUsage = async () => {
try {
const response = await api.get('/admin/storage/usage');
setStorageUsed(response.data.total_bytes_used);
setStorageLimit(response.data.max_bytes_allowed);
setStoragePercentage(response.data.percentage);
} catch (error) {
console.error('Failed to fetch storage usage:', error);
}
};
fetchStorageUsage();
// Refresh storage usage every 60 seconds
const interval = setInterval(fetchStorageUsage, 60000);
return () => clearInterval(interval);
}, []);
const formatBytes = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleLogout = () => {
logout();
navigate('/login');
@@ -85,7 +120,31 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
name: 'Events',
icon: Calendar,
path: '/admin/events',
disabled: true
disabled: false
},
{
name: 'Gallery',
icon: Image,
path: '/admin/gallery',
disabled: false
},
{
name: 'Newsletters',
icon: FileText,
path: '/admin/newsletters',
disabled: false
},
{
name: 'Financials',
icon: DollarSign,
path: '/admin/financials',
disabled: false
},
{
name: 'Bylaws',
icon: Scale,
path: '/admin/bylaws',
disabled: false
},
{
name: 'Roles',
@@ -233,6 +292,48 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div>
)}
{/* Storage Usage Widget */}
<div className="mb-2">
{isOpen ? (
<div className="px-4 py-3 bg-[#F8F7FB] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[#422268]">Storage Usage</span>
<span className="text-xs text-[#664fa3]">{storagePercentage}%</span>
</div>
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 75 ? 'bg-yellow-500' :
'bg-[#81B29A]'
}`}
style={{ width: `${storagePercentage}%` }}
/>
</div>
<p className="text-xs text-[#664fa3] mt-1">
{formatBytes(storageUsed)} / {formatBytes(storageLimit)}
</p>
</div>
) : (
<div className="flex justify-center">
<div className="relative group">
<HardDrive className={`h-5 w-5 ${
storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 75 ? 'text-yellow-500' :
'text-[#664fa3]'
}`} />
{storagePercentage > 75 && (
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
)}
{/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
Storage: {storagePercentage}%
</div>
</div>
</div>
)}
</div>
{/* Logout Button */}
<button
onClick={handleLogout}

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Calendar, Users, User, BookOpen, FileText, DollarSign, Scale } from 'lucide-react';
const MemberFooter = () => {
return (
<footer className="bg-[#422268] text-white mt-auto">
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-4 gap-8">
{/* Logo & About */}
<div>
<h3 className="text-2xl font-bold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
LOAF
</h3>
<p className="text-gray-300 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Lesbian Organization of Atlanta Family
</p>
</div>
{/* Member Resources */}
<div>
<h4 className="font-semibold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Member Resources
</h4>
<ul className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<li>
<Link to="/members/calendar" className="text-gray-300 hover:text-white flex items-center gap-2 transition-colors">
<Calendar className="h-4 w-4" />
Event Calendar
</Link>
</li>
<li>
<Link to="/members/directory" className="text-gray-300 hover:text-white flex items-center gap-2 transition-colors">
<Users className="h-4 w-4" />
Members Directory
</Link>
</li>
<li>
<Link to="/members/profile" className="text-gray-300 hover:text-white flex items-center gap-2 transition-colors">
<User className="h-4 w-4" />
My Profile
</Link>
</li>
<li>
<Link to="/events" className="text-gray-300 hover:text-white flex items-center gap-2 transition-colors">
<BookOpen className="h-4 w-4" />
Events
</Link>
</li>
</ul>
</div>
{/* Documents */}
<div>
<h4 className="font-semibold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Documents
</h4>
<ul className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<li>
<Link to="/members/newsletters" className="text-gray-300 hover:text-white flex items-center gap-2 transition-colors">
<FileText className="h-4 w-4" />
Newsletters
</Link>
</li>
<li>
<Link to="/members/financials" className="text-gray-300 hover:text-white flex items-center gap-2 transition-colors">
<DollarSign className="h-4 w-4" />
Financial Reports
</Link>
</li>
<li>
<Link to="/members/bylaws" className="text-gray-300 hover:text-white flex items-center gap-2 transition-colors">
<Scale className="h-4 w-4" />
Bylaws
</Link>
</li>
</ul>
</div>
{/* Support */}
<div>
<h4 className="font-semibold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Support
</h4>
<ul className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<li>
<Link to="/profile" className="text-gray-300 hover:text-white transition-colors">
Account Settings
</Link>
</li>
<li>
<a href="/#contact" className="text-gray-300 hover:text-white transition-colors">
Contact Us
</a>
</li>
<li>
<a href="/#donate" className="text-gray-300 hover:text-white transition-colors">
Donate
</a>
</li>
</ul>
</div>
</div>
</div>
{/* Bottom Bar */}
<div className="border-t border-[#664fa3]">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-400" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div className="flex gap-6">
<a href="/#terms" className="hover:text-white transition-colors">Terms of Service</a>
<a href="/#privacy" className="hover:text-white transition-colors">Privacy Policy</a>
</div>
<p>© 2025 LOAF. All rights reserved.</p>
</div>
</div>
</div>
</footer>
);
};
export default MemberFooter;

View File

@@ -2,93 +2,124 @@ import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Button } from './ui/button';
import { Users, LogOut, LayoutDashboard } from 'lucide-react';
const Navbar = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
// LOAF logo (local)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<nav className="bg-white border-b border-[#ddd8eb] sticky top-0 z-50 backdrop-blur-sm bg-white/90">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex justify-between items-center">
<Link to={user ? "/dashboard" : "/"} className="flex items-center gap-2">
<Users className="h-8 w-8 text-[#ff9e77]" strokeWidth={1.5} />
<span className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>Membership</span>
<>
{/* Top Header - Member Actions */}
<header className="bg-gradient-to-r from-[#644c9f] to-[#48286e] px-16 py-4 flex justify-end items-center gap-6">
{user && (
<span className="text-white text-base font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>
Welcome, {user.first_name}
</span>
)}
{user?.role === 'admin' && (
<Link to="/admin">
<button
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="admin-nav-button"
>
Admin Panel
</button>
</Link>
<div className="flex items-center gap-4">
{user ? (
<>
{user.role === 'admin' && (
<Link to="/admin">
<Button
variant="ghost"
className="text-[#422268] hover:text-[#ff9e77] hover:bg-[#DDD8EB]/10"
data-testid="admin-nav-button"
>
<LayoutDashboard className="h-4 w-4 mr-2" />
Admin
</Button>
</Link>
)}
<Link to="/events">
<Button
variant="ghost"
className="text-[#422268] hover:text-[#ff9e77] hover:bg-[#DDD8EB]/10"
data-testid="events-nav-button"
>
Events
</Button>
</Link>
<Link to="/profile">
<Button
variant="ghost"
className="text-[#422268] hover:text-[#ff9e77] hover:bg-[#DDD8EB]/10"
data-testid="profile-nav-button"
>
Profile
</Button>
</Link>
<Button
onClick={handleLogout}
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
data-testid="logout-button"
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</Button>
</>
) : (
<>
<Link to="/login">
<Button
variant="ghost"
className="text-[#422268] hover:text-[#ff9e77] hover:bg-[#DDD8EB]/10"
data-testid="login-nav-button"
>
Login
</Button>
</Link>
<Link to="/register">
<Button
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
data-testid="register-nav-button"
>
Join Us
</Button>
</Link>
</>
)}
</div>
</div>
</div>
</nav>
)}
<button
onClick={handleLogout}
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="logout-button"
>
Logout
</button>
<Link to="/#donate">
<Button
className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-[54px] py-[10px] text-[16.5px] font-semibold h-[41px]"
style={{ fontFamily: "'Montserrat', sans-serif" }}
>
Donate
</Button>
</Link>
</header>
{/* Main Header - Member Navigation */}
<header className="bg-[#664fa3] px-16 py-2 flex justify-between items-center">
<Link to="/dashboard">
<img src={loafLogo} alt="LOAF Logo" className="h-28 w-28 object-contain" />
</Link>
<nav className="flex gap-10 items-center">
<Link
to="/"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Home
</Link>
<Link
to="/dashboard"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Dashboard
</Link>
<Link
to="/events"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="events-nav-button"
>
Events
</Link>
<Link
to="/members/calendar"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Calendar
</Link>
<Link
to="/members/directory"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Directory
</Link>
<Link
to="/members/gallery"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Gallery
</Link>
<Link
to="/members/newsletters"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Documents
</Link>
<Link
to="/profile"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="profile-nav-button"
>
Profile
</Link>
</nav>
</header>
</>
);
};

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Button } from './ui/button';
const PublicFooter = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
return (
<>
{/* Main Footer */}
<footer className="bg-[#644c9f] px-16 py-0 flex items-center justify-center h-[420px]">
<div className="border-t border-[rgba(0,0,0,0.1)] py-20 flex gap-30 items-center justify-center flex-1">
<div className="w-[232px]">
<img src={loafLogo} alt="LOAF Logo" className="w-[232px] h-[232px] object-contain" />
</div>
<nav className="flex gap-28 items-start justify-center w-[811px]">
<div className="flex flex-col gap-2 w-[163px]">
<div className="pb-4">
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>About</p>
</div>
<a href="/#history" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>History</a>
<a href="/#mission" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Mission and Values</a>
<a href="/#board" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Board of Directors</a>
</div>
<div className="flex flex-col gap-2 w-[148px]">
<div className="pb-4">
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Connect</p>
</div>
<Link to="/become-a-member" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Become a Member</Link>
<a href="/#contact" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Contact Us</a>
<a href="/#resources" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Resources</a>
</div>
<div className="flex flex-col gap-2 items-center w-[271px]">
<div className="pb-4 w-full">
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-full px-6 py-3 text-lg font-medium w-[217px]">
Donate
</Button>
</div>
<p className="text-[#ddd8eb] text-base font-medium text-center w-full" style={{ fontFamily: "'Inter', sans-serif" }}>
LOAF is supported by<br />the Hollyfield Foundation
</p>
</div>
</nav>
</div>
</footer>
{/* Bottom Footer */}
<footer className="bg-gradient-to-r from-[#48286e] to-[#644c9f] border-t border-[rgba(0,0,0,0.1)] px-16 py-6 flex justify-between items-center h-[76px]">
<nav className="flex gap-8 items-center">
<a href="/#terms" className="text-[#c5b4e3] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>
Terms of Service
</a>
<a href="/#privacy" className="text-[#c5b4e3] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>
Privacy Policy
</a>
</nav>
<p className="text-[#c5b4e3] text-base font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
© 2025 LOAF. All Rights Reserved.
</p>
<p className="text-[#c5b4e3] text-base font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Designed and Managed by{' '}
<a href="https://konceptkit.com/" className="text-[#d1c3e9] underline hover:text-white transition-colors">
Koncept Kit
</a>
</p>
</footer>
</>
);
};
export default PublicFooter;

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Button } from './ui/button';
import { useAuth } from '../context/AuthContext';
const PublicNavbar = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
// LOAF logo (local)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
const handleAuthAction = () => {
if (user) {
logout();
navigate('/');
} else {
navigate('/login');
}
};
return (
<>
{/* Top Header - Auth Actions */}
<header className="bg-gradient-to-r from-[#644c9f] to-[#48286e] px-16 py-4 flex justify-end items-center gap-6">
<button
onClick={handleAuthAction}
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
{user ? 'Logout' : 'Login'}
</button>
{!user && (
<Link
to="/register"
className="text-white text-base font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Register
</Link>
)}
<Link to="/#donate">
<Button
className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-[54px] py-[10px] text-[16.5px] font-semibold h-[41px]"
style={{ fontFamily: "'Montserrat', sans-serif" }}
>
Donate
</Button>
</Link>
</header>
{/* Main Header - Navigation */}
<header className="bg-[#664fa3] px-16 py-2 flex justify-between items-center">
<Link to="/">
<img src={loafLogo} alt="LOAF Logo" className="h-28 w-28 object-contain" />
</Link>
<nav className="flex gap-10 items-center">
<Link
to="/#welcome"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Welcome
</Link>
<Link
to="/#about"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
About Us
</Link>
<Link
to={user ? "/dashboard" : "/become-a-member"}
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
{user ? 'Dashboard' : 'Become a Member'}
</Link>
{!user && (
<Link
to="/login"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Members Only
</Link>
)}
<Link
to="/#resources"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Resources
</Link>
<Link
to="/#contact"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Contact Us
</Link>
</nav>
</header>
</>
);
};
export default PublicNavbar;

241
src/pages/BecomeMember.js Normal file
View File

@@ -0,0 +1,241 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../components/ui/button';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { ArrowDown } from 'lucide-react';
const BecomeMember = () => {
// Image assets from Figma
const imgIconAdminFee1 = "https://www.figma.com/api/mcp/asset/e4e0af2e-b461-4f68-b8c7-2e49a94095d4";
const imgIconAdminFee2 = "https://www.figma.com/api/mcp/asset/df5b86ce-18c0-470e-8ea3-f6af3dabbd9d";
const imgIconAdminFee3 = "https://www.figma.com/api/mcp/asset/ad064775-46ab-4b54-883f-d09757971162";
const imgIconAdminFee4 = "https://www.figma.com/api/mcp/asset/7fac9483-07ff-4cfd-8ea6-ab0509ef62a9";
const imgIconAdminFee5 = "https://www.figma.com/api/mcp/asset/b89a44fe-a041-4611-9e2d-fa88d9223204";
const imgShootingStar = "https://www.figma.com/api/mcp/asset/f1afecdc-d65c-4787-8672-8fa02f66e4c3";
return (
<div className="min-h-screen bg-gray-50 relative">
<PublicNavbar />
{/* Decorative shooting star element */}
<div className="absolute left-[88px] top-[974px] w-[195px] h-[1135px] pointer-events-none opacity-50">
<img
src={imgShootingStar}
alt=""
className="w-full h-full object-contain"
/>
</div>
{/* Hero Section */}
<div className="relative bg-gray-50 pt-20 pb-24">
<div className="max-w-7xl mx-auto px-6 text-center">
<h1
className="text-[48px] font-bold text-[#48286e] mb-8 leading-[1.2] tracking-[-0.96px]"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Become a Member
</h1>
<p
className="text-[19px] font-medium text-[#48286e] max-w-[689px] mx-auto leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
>
Become a member to receive our monthly newsletter and find out about all the activities LOAF has planned each month. LOAF hosts over 40 social activities each year and occasionally covers the costs for members only
</p>
</div>
</div>
{/* Annual Administrative Fees Section */}
<div className="max-w-[1340px] mx-auto px-6 mb-16">
<div className="flex gap-5 items-center">
<div className="w-[153px] h-[138px] flex-shrink-0">
<img
src={imgIconAdminFee1}
alt="Admin Fee Icon"
className="w-full h-full object-contain"
/>
</div>
<div className="flex-1 bg-[#eeebf4] rounded-[25px] px-8 py-8">
<h3
className="text-[32px] font-semibold text-[#48286e] mb-5 leading-[1.49]"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Annual Administrative Fees
</h3>
<p
className="text-[19px] font-medium text-[#48286e] leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
>
Annual Administrative Fees for all members are $30 per person. These fees help cover general business expenses (website, advertising, e-newsletter).
</p>
</div>
</div>
</div>
{/* Membership Process Section */}
<div className="relative bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-6 text-center">
<h2
className="text-[40px] font-bold text-[#48286e] mb-8 leading-[1.2] tracking-[-0.8px]"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Membership Process
</h2>
<p
className="text-[19px] font-medium text-[#48286e] max-w-[689px] mx-auto leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
>
Becoming a member is easy, but for the safety and privacy of our membership, there are a few steps:
</p>
</div>
</div>
{/* Step 1 */}
<div className="max-w-[1340px] mx-auto px-6 mb-8">
<div className="flex gap-5 items-center">
<div className="w-[153px] h-[138px] flex-shrink-0">
<img
src={imgIconAdminFee2}
alt="Step 1 Icon"
className="w-full h-full object-contain"
/>
</div>
<div className="flex-1 bg-white rounded-[25px] px-8 py-8">
<h3
className="text-[32px] font-semibold text-[#48286e] mb-5 leading-[1.49]"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Step 1: Application & Email Confirmation
</h3>
<p
className="text-[19px] font-medium text-[#48286e] leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
>
Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are approved by an admin.
</p>
</div>
</div>
</div>
{/* Arrow Down Icon */}
<div className="flex justify-center mb-8">
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
</div>
{/* Step 2 */}
<div className="max-w-[1340px] mx-auto px-6 mb-8">
<div className="flex gap-5 items-center">
<div className="w-[153px] h-[138px] flex-shrink-0">
<img
src={imgIconAdminFee3}
alt="Step 2 Icon"
className="w-full h-full object-contain"
/>
</div>
<div className="flex-1 bg-white rounded-[25px] px-8 py-8">
<h3
className="text-[32px] font-semibold text-[#48286e] mb-5 leading-[1.49]"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Step 2: Attend an event and meet us!
</h3>
<p
className="text-[19px] font-medium text-[#48286e] leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
>
You have 3 months to attend a LOAF event and introduce yourself to a board member. If you do not attend an event within 3 months, you will no longer receive the e-newsletter. (This step can be skipped if you have been referred from a current member and list her on your registration form).
</p>
</div>
</div>
</div>
{/* Arrow Down Icon */}
<div className="flex justify-center mb-8">
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
</div>
{/* Step 3 */}
<div className="max-w-[1340px] mx-auto px-6 mb-8">
<div className="flex gap-5 items-center">
<div className="w-[153px] h-[138px] flex-shrink-0">
<img
src={imgIconAdminFee4}
alt="Step 3 Icon"
className="w-full h-full object-contain"
/>
</div>
<div className="flex-1 bg-white rounded-[25px] px-8 py-8">
<h3
className="text-[32px] font-semibold text-[#48286e] mb-5 leading-[1.49]"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Step 3: Login and pay the annual fee
</h3>
<p
className="text-[19px] font-medium text-[#48286e] leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
>
Once we know that you are indeed you, an admin will approve your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.
</p>
</div>
</div>
</div>
{/* Arrow Down Icon */}
<div className="flex justify-center mb-8">
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
</div>
{/* Step 4 - With Gradient Background */}
<div className="max-w-[1340px] mx-auto px-6 mb-16">
<div className="flex gap-5 items-center">
<div className="w-[153px] h-[138px] flex-shrink-0">
<img
src={imgIconAdminFee5}
alt="Step 4 Icon"
className="w-full h-full object-contain"
/>
</div>
<div className="flex-1 bg-gradient-to-r from-[#48286e] to-[#664fa3] rounded-[25px] px-8 py-8">
<h3
className="text-[32px] font-semibold text-white mb-5 leading-[1.49]"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Step 4: Welcome to LOAF!
</h3>
<p
className="text-[19px] font-medium text-white leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
>
Congratulations! Your application is complete, and you now have access to Members Only content. We hope to see you at future events soon!
</p>
</div>
</div>
</div>
{/* CTA Section */}
<div className="relative bg-gray-50 py-16">
<div className="max-w-7xl mx-auto px-6 text-center">
<h2
className="text-[40px] font-bold text-[#48286e] mb-8 leading-[1.2] tracking-[-0.8px]"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Ready to Join Us?
</h2>
<Link to="/register">
<Button
className="bg-[#664fa3] text-white hover:bg-[#48286e] rounded-[35px] px-16 py-6 text-[18px] font-medium tracking-[-0.09px] h-[50px]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Register Now!
</Button>
</Link>
</div>
</div>
<PublicFooter />
</div>
);
};
export default BecomeMember;

View File

@@ -6,11 +6,12 @@ import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail } from 'lucide-react';
import MemberFooter from '../components/MemberFooter';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale } from 'lucide-react';
import { toast } from 'sonner';
const Dashboard = () => {
const { user, resendVerificationEmail } = useAuth();
const { user, resendVerificationEmail, refreshUser } = useAuth();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [resendLoading, setResendLoading] = useState(false);
@@ -38,7 +39,18 @@ const Dashboard = () => {
toast.success('Verification email sent! Please check your inbox.');
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
toast.error(errorMessage);
// If email is already verified, refresh user data to update UI
if (errorMessage === 'Email is already verified') {
try {
await refreshUser();
toast.success('Your email is already verified!');
} catch (refreshError) {
toast.error('Email is already verified. Please refresh the page.');
}
} else {
toast.error(errorMessage);
}
} finally {
setResendLoading(false);
}
@@ -168,6 +180,22 @@ const Dashboard = () => {
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
</p>
</div>
{user?.subscription_start_date && user?.subscription_end_date && (
<>
<div className="pt-4 border-t border-[#ddd8eb]">
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
</p>
</div>
</>
)}
</div>
</Card>
@@ -265,6 +293,7 @@ const Dashboard = () => {
</Card>
)}
</div>
<MemberFooter />
</div>
);
};

View File

@@ -6,6 +6,8 @@ import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter';
import AddToCalendarButton from '../components/AddToCalendarButton';
import { Calendar, MapPin, Users, ArrowLeft, Check, X, HelpCircle } from 'lucide-react';
const EventDetails = () => {
@@ -191,9 +193,25 @@ const EventDetails = () => {
Can't Attend
</Button>
</div>
{/* Add to Calendar Section */}
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
<h2 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Add to Your Calendar
</h2>
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Never miss this event! Add it to your calendar app for reminders.
</p>
<AddToCalendarButton
event={event}
showSubscribe={false}
variant="outline"
/>
</div>
</div>
</Card>
</div>
<MemberFooter />
</div>
);
};

View File

@@ -4,6 +4,7 @@ import api from '../utils/api';
import { Card } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter';
import { Calendar, MapPin, Users, ArrowRight } from 'lucide-react';
const Events = () => {
@@ -125,6 +126,7 @@ const Events = () => {
</div>
)}
</div>
<MemberFooter />
</div>
);
};

View File

@@ -6,7 +6,8 @@ import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Card } from '../components/ui/card';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { ArrowRight, ArrowLeft, Mail, CheckCircle } from 'lucide-react';
const ForgotPassword = () => {
@@ -32,7 +33,7 @@ const ForgotPassword = () => {
return (
<div className="min-h-screen bg-white">
<Navbar />
<PublicNavbar />
<div className="max-w-md mx-auto px-6 py-12">
<div className="mb-8">
@@ -114,6 +115,8 @@ const ForgotPassword = () => {
)}
</Card>
</div>
<PublicFooter />
</div>
);
};

View File

@@ -2,56 +2,21 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
const Landing = () => {
// LOAF brand assets from Figma
const loafLogo = "https://www.figma.com/api/mcp/asset/5e3c057c-ffb0-4ceb-aa60-70a5ddbe10ab";
const taglineImage = "https://www.figma.com/api/mcp/asset/ce184873-0c46-45eb-8480-76a9411011bf";
const shootingStar = "https://www.figma.com/api/mcp/asset/6db528f4-493b-4560-9ff6-076bc42421ca";
const iconMeetGreet = "https://www.figma.com/api/mcp/asset/3519deee-3f78-45e1-b537-f9c61102c18b";
const iconSocials = "https://www.figma.com/api/mcp/asset/e95f23c6-e893-472e-a3cf-38a05290790b";
const iconActive = "https://www.figma.com/api/mcp/asset/cab1679a-a7d6-4a5f-8732-c6c1cd578320";
const heroLoaf = "https://www.figma.com/api/mcp/asset/f3fb05af-0fef-49b7-bc50-f5914dfe1705";
// LOAF brand assets (local)
const taglineImage = `${process.env.PUBLIC_URL}/tagline-image.png`;
const shootingStar = `${process.env.PUBLIC_URL}/shooting-star.png`;
const iconMeetGreet = `${process.env.PUBLIC_URL}/icon-meet-greet.png`;
const iconSocials = `${process.env.PUBLIC_URL}/icon-socials.png`;
const iconActive = `${process.env.PUBLIC_URL}/icon-active.png`;
const heroLoaf = `${process.env.PUBLIC_URL}/hero-loaf.png`;
return (
<div className="min-h-screen bg-white">
{/* Top Header - Auth Actions */}
<header className="bg-gradient-to-r from-[#644c9f] to-[#48286e] px-16 py-4 flex justify-end items-center gap-6">
<Link to="/register" className="text-white text-base font-medium hover:opacity-80 transition-opacity">
Register
</Link>
<Link to="/login" className="text-white text-base font-medium hover:opacity-80 transition-opacity">
Login
</Link>
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#644c9f] rounded-full px-6 py-3 text-base font-medium">
Donate
</Button>
</header>
{/* Main Header - Navigation */}
<header className="bg-[#664fa3] px-16 py-2 flex justify-between items-center">
<img src={loafLogo} alt="LOAF Logo" className="h-28 w-28 object-contain" />
<nav className="flex gap-10 items-center">
<a href="#welcome" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
Welcome
</a>
<a href="#about" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
About Us
</a>
<Link to="/register" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
Become a Member
</Link>
<Link to="/login" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
Members Only
</Link>
<a href="#resources" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
Resources
</a>
<a href="#contact" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
Contact Us
</a>
</nav>
</header>
<PublicNavbar />
{/* Hero Section */}
<section className="bg-gradient-to-b from-[#48286e] to-[#664fa3] px-16 py-0 flex gap-16 items-center justify-center">
@@ -144,63 +109,7 @@ const Landing = () => {
</div>
</section>
{/* Main Footer */}
<footer className="bg-[#644c9f] px-16 py-0 flex items-center justify-center h-[420px]">
<div className="border-t border-[rgba(0,0,0,0.1)] py-20 flex gap-30 items-center justify-center flex-1">
<div className="w-[232px]">
<img src={loafLogo} alt="LOAF Logo" className="w-[232px] h-[232px] object-contain" />
</div>
<nav className="flex gap-28 items-start justify-center w-[811px]">
<div className="flex flex-col gap-2 w-[163px]">
<div className="pb-4">
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>About</p>
</div>
<a href="#history" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>History</a>
<a href="#mission" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Mission and Values</a>
<a href="#board" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Board of Directors</a>
</div>
<div className="flex flex-col gap-2 w-[148px]">
<div className="pb-4">
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Connect</p>
</div>
<Link to="/register" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Become a Member</Link>
<a href="#contact" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Contact Us</a>
<a href="#resources" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Resources</a>
</div>
<div className="flex flex-col gap-2 items-center w-[271px]">
<div className="pb-4 w-full">
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-full px-6 py-3 text-lg font-medium w-[217px]">
Donate
</Button>
</div>
<p className="text-[#ddd8eb] text-base font-medium text-center w-full" style={{ fontFamily: "'Inter', sans-serif" }}>
LOAF is supported by<br />the Hollyfield Foundation
</p>
</div>
</nav>
</div>
</footer>
{/* Bottom Footer */}
<footer className="bg-gradient-to-r from-[#48286e] to-[#644c9f] border-t border-[rgba(0,0,0,0.1)] px-16 py-6 flex justify-between items-center h-[76px]">
<nav className="flex gap-8 items-center">
<a href="#terms" className="text-[#c5b4e3] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>
Terms of Service
</a>
<a href="#privacy" className="text-[#c5b4e3] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>
Privacy Policy
</a>
</nav>
<p className="text-[#c5b4e3] text-base font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
© 2025 LOAF. All Rights Reserved.
</p>
<p className="text-[#c5b4e3] text-base font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Designed and Managed by{' '}
<a href="https://konceptkit.com/" className="text-[#d1c3e9] underline hover:text-white transition-colors">
Koncept Kit
</a>
</p>
</footer>
<PublicFooter />
</div>
);
};

View File

@@ -7,7 +7,8 @@ import { PasswordInput } from '../components/ui/password-input';
import { Label } from '../components/ui/label';
import { Card } from '../components/ui/card';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { ArrowRight, ArrowLeft } from 'lucide-react';
const Login = () => {
@@ -55,7 +56,7 @@ const Login = () => {
return (
<div className="min-h-screen bg-white">
<Navbar />
<PublicNavbar />
<div className="max-w-md mx-auto px-6 py-12">
<div className="mb-8">
@@ -129,6 +130,8 @@ const Login = () => {
</form>
</Card>
</div>
<PublicFooter />
</div>
);
};

View File

@@ -7,6 +7,7 @@ import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter';
import { User, Save, Lock } from 'lucide-react';
import ChangePasswordDialog from '../components/ChangePasswordDialog';
@@ -242,6 +243,7 @@ const Profile = () => {
onOpenChange={setPasswordDialogOpen}
/>
</div>
<MemberFooter />
</div>
);
};

View File

@@ -4,7 +4,8 @@ import { useAuth } from '../context/AuthContext';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { ArrowRight, ArrowLeft } from 'lucide-react';
import RegistrationStepIndicator from '../components/registration/RegistrationStepIndicator';
import RegistrationStep1 from '../components/registration/RegistrationStep1';
@@ -183,7 +184,7 @@ const Register = () => {
return (
<div className="min-h-screen bg-white">
<Navbar />
<PublicNavbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-8">
@@ -284,6 +285,8 @@ const Register = () => {
</form>
</Card>
</div>
<PublicFooter />
</div>
);
};

View File

@@ -6,7 +6,8 @@ import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Card } from '../components/ui/card';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { ArrowRight, Lock, AlertCircle } from 'lucide-react';
const ResetPassword = () => {
@@ -64,7 +65,7 @@ const ResetPassword = () => {
return (
<div className="min-h-screen bg-white">
<Navbar />
<PublicNavbar />
<div className="max-w-md mx-auto px-6 py-12">
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
@@ -140,6 +141,8 @@ const ResetPassword = () => {
</form>
</Card>
</div>
<PublicFooter />
</div>
);
};

View File

@@ -4,7 +4,8 @@ import axios from 'axios';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import Navbar from '../components/Navbar';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
const API_URL = process.env.REACT_APP_BACKEND_URL;
@@ -38,7 +39,7 @@ const VerifyEmail = () => {
return (
<div className="min-h-screen bg-white">
<Navbar />
<PublicNavbar />
<div className="max-w-2xl mx-auto px-6 py-20">
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg text-center">
@@ -98,6 +99,8 @@ const VerifyEmail = () => {
)}
</Card>
</div>
<PublicFooter />
</div>
);
};

View File

@@ -0,0 +1,477 @@
import React, { useEffect, useState } from 'react';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Checkbox } from '../../components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import { toast } from 'sonner';
import {
Scale,
Plus,
Edit,
Trash2,
ExternalLink,
Check
} from 'lucide-react';
const AdminBylaws = () => {
const [bylaws, setBylaws] = useState([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedBylaws, setSelectedBylaws] = useState(null);
const [bylawsToDelete, setBylawsToDelete] = useState(null);
const [uploadedFile, setUploadedFile] = useState(null);
const [formData, setFormData] = useState({
title: '',
version: '',
effective_date: '',
document_url: '',
document_type: 'google_drive',
is_current: false
});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetchBylaws();
}, []);
const fetchBylaws = async () => {
try {
const response = await api.get('/bylaws/history');
setBylaws(response.data);
} catch (error) {
toast.error('Failed to fetch bylaws');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setSelectedBylaws(null);
setFormData({
title: 'LOAF Bylaws',
version: '',
effective_date: new Date().toISOString().split('T')[0],
document_url: '',
document_type: 'google_drive',
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
});
setDialogOpen(true);
};
const handleEdit = (bylawsDoc) => {
setSelectedBylaws(bylawsDoc);
setFormData({
title: bylawsDoc.title,
version: bylawsDoc.version,
effective_date: new Date(bylawsDoc.effective_date).toISOString().split('T')[0],
document_url: bylawsDoc.document_url,
document_type: bylawsDoc.document_type,
is_current: bylawsDoc.is_current
});
setDialogOpen(true);
};
const handleDelete = (bylawsDoc) => {
setBylawsToDelete(bylawsDoc);
setDeleteDialogOpen(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
try {
const formDataToSend = new FormData();
formDataToSend.append('title', formData.title);
formDataToSend.append('version', formData.version);
formDataToSend.append('effective_date', new Date(formData.effective_date).toISOString());
formDataToSend.append('document_type', formData.document_type);
formDataToSend.append('is_current', formData.is_current);
// Handle file upload or URL based on document_type
if (formData.document_type === 'upload') {
if (!uploadedFile && !selectedBylaws) {
toast.error('Please select a file to upload');
setSubmitting(false);
return;
}
if (uploadedFile) {
formDataToSend.append('file', uploadedFile);
}
} else {
formDataToSend.append('document_url', formData.document_url);
}
if (selectedBylaws) {
await api.put(`/admin/bylaws/${selectedBylaws.id}`, formDataToSend);
toast.success('Bylaws updated successfully');
} else {
await api.post('/admin/bylaws', formDataToSend);
toast.success('Bylaws created successfully');
}
setDialogOpen(false);
setUploadedFile(null);
fetchBylaws();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save bylaws');
} finally {
setSubmitting(false);
}
};
const confirmDelete = async () => {
try {
await api.delete(`/admin/bylaws/${bylawsToDelete.id}`);
toast.success('Bylaws deleted successfully');
setDeleteDialogOpen(false);
fetchBylaws();
} catch (error) {
toast.error('Failed to delete bylaws');
}
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const currentBylaws = bylaws.find(b => b.is_current);
const historicalBylaws = bylaws.filter(b => !b.is_current).sort((a, b) =>
new Date(b.effective_date) - new Date(a.effective_date)
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#664fa3]">Loading bylaws...</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Bylaws Management
</h1>
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage LOAF governing bylaws and version history
</p>
</div>
<Button
onClick={handleCreate}
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Version
</Button>
</div>
{/* Current Bylaws */}
{currentBylaws ? (
<Card className="p-6 border-2 border-[#664fa3]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-3 rounded-xl">
<Scale className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="text-xl font-semibold text-[#422268]">
{currentBylaws.title}
</h3>
<div className="flex items-center gap-2 mt-1">
<Badge className="bg-[#81B29A] text-white">
<Check className="h-3 w-3 mr-1" />
Current Version
</Badge>
<span className="text-[#664fa3] text-sm">
Version {currentBylaws.version}
</span>
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.open(currentBylaws.document_url, '_blank')}
className="border-[#664fa3] text-[#664fa3]"
>
<ExternalLink className="h-4 w-4 mr-1" />
View
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(currentBylaws)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(currentBylaws)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
<span></span>
<span>Document Type: <strong>{currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()}</strong></span>
</div>
</Card>
) : (
<Card className="p-12 text-center">
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create Bylaws
</Button>
</Card>
)}
{/* Historical Versions */}
{historicalBylaws.length > 0 && (
<div>
<h2 className="text-xl font-semibold text-[#422268] mb-4">
Version History ({historicalBylaws.length})
</h2>
<div className="space-y-4">
{historicalBylaws.map(bylawsDoc => (
<Card key={bylawsDoc.id} className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-1">
{bylawsDoc.title}
</h3>
<div className="flex items-center gap-3 text-sm text-[#664fa3]">
<span>Version {bylawsDoc.version}</span>
<span></span>
<span>Effective {formatDate(bylawsDoc.effective_date)}</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.open(bylawsDoc.document_url, '_blank')}
className="border-[#664fa3] text-[#664fa3]"
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(bylawsDoc)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(bylawsDoc)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selectedBylaws ? 'Edit Bylaws' : 'Add Bylaws Version'}
</DialogTitle>
<DialogDescription>
{selectedBylaws ? 'Update bylaws information' : 'Add a new version of the bylaws'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="LOAF Bylaws"
required
/>
</div>
<div>
<Label htmlFor="version">Version *</Label>
<Input
id="version"
value={formData.version}
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
placeholder="v1.0"
required
/>
</div>
<div>
<Label htmlFor="effective_date">Effective Date *</Label>
<Input
id="effective_date"
type="date"
value={formData.effective_date}
onChange={(e) => setFormData({ ...formData, effective_date: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="document_type">Document Type *</Label>
<Select
value={formData.document_type}
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google_drive">Google Drive</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="upload">Upload</SelectItem>
</SelectContent>
</Select>
</div>
{formData.document_type === 'upload' ? (
<div>
<Label htmlFor="document_file">Upload PDF File *</Label>
<Input
id="document_file"
type="file"
accept=".pdf"
onChange={(e) => setUploadedFile(e.target.files[0])}
required={!selectedBylaws}
/>
{uploadedFile && (
<p className="text-sm text-[#664fa3] mt-1">
Selected: {uploadedFile.name}
</p>
)}
</div>
) : (
<div>
<Label htmlFor="document_url">Document URL *</Label>
<Input
id="document_url"
value={formData.document_url}
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
placeholder="https://drive.google.com/file/d/..."
required
/>
<p className="text-sm text-[#664fa3] mt-1">
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
</p>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="is_current"
checked={formData.is_current}
onCheckedChange={(checked) => setFormData({ ...formData, is_current: checked })}
/>
<label
htmlFor="is_current"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Set as current version (will unset all other versions)
</label>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
className="bg-[#664fa3] text-white"
disabled={submitting}
>
{submitting ? 'Saving...' : selectedBylaws ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Bylaws</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{bylawsToDelete?.title} ({bylawsToDelete?.version})"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
className="bg-red-500 hover:bg-red-600"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default AdminBylaws;

View File

@@ -0,0 +1,374 @@
import React, { useEffect, useState } from 'react';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import { toast } from 'sonner';
import {
DollarSign,
Plus,
Edit,
Trash2,
ExternalLink,
TrendingUp
} from 'lucide-react';
const AdminFinancials = () => {
const [reports, setReports] = useState([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedReport, setSelectedReport] = useState(null);
const [reportToDelete, setReportToDelete] = useState(null);
const [uploadedFile, setUploadedFile] = useState(null);
const [formData, setFormData] = useState({
year: new Date().getFullYear(),
title: '',
document_url: '',
document_type: 'google_drive'
});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetchReports();
}, []);
const fetchReports = async () => {
try {
const response = await api.get('/financials');
setReports(response.data);
} catch (error) {
toast.error('Failed to fetch financial reports');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setSelectedReport(null);
setFormData({
year: new Date().getFullYear(),
title: '',
document_url: '',
document_type: 'google_drive'
});
setDialogOpen(true);
};
const handleEdit = (report) => {
setSelectedReport(report);
setFormData({
year: report.year,
title: report.title,
document_url: report.document_url,
document_type: report.document_type
});
setDialogOpen(true);
};
const handleDelete = (report) => {
setReportToDelete(report);
setDeleteDialogOpen(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
try {
const formDataToSend = new FormData();
formDataToSend.append('year', formData.year);
formDataToSend.append('title', formData.title);
formDataToSend.append('document_type', formData.document_type);
// Handle file upload or URL based on document_type
if (formData.document_type === 'upload') {
if (!uploadedFile && !selectedReport) {
toast.error('Please select a file to upload');
setSubmitting(false);
return;
}
if (uploadedFile) {
formDataToSend.append('file', uploadedFile);
}
} else {
formDataToSend.append('document_url', formData.document_url);
}
if (selectedReport) {
await api.put(`/admin/financials/${selectedReport.id}`, formDataToSend);
toast.success('Financial report updated successfully');
} else {
await api.post('/admin/financials', formDataToSend);
toast.success('Financial report created successfully');
}
setDialogOpen(false);
setUploadedFile(null);
fetchReports();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save financial report');
} finally {
setSubmitting(false);
}
};
const confirmDelete = async () => {
try {
await api.delete(`/admin/financials/${reportToDelete.id}`);
toast.success('Financial report deleted successfully');
setDeleteDialogOpen(false);
fetchReports();
} catch (error) {
toast.error('Failed to delete financial report');
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#664fa3]">Loading financial reports...</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Financial Reports Management
</h1>
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage annual financial reports
</p>
</div>
<Button
onClick={handleCreate}
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Report
</Button>
</div>
{/* Reports List */}
{reports.length === 0 ? (
<Card className="p-12 text-center">
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create First Report
</Button>
</Card>
) : (
<div className="space-y-4">
{reports.map(report => (
<Card key={report.id} className="p-6">
<div className="flex items-center gap-6">
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-4 rounded-xl text-white min-w-[100px] text-center">
<DollarSign className="h-6 w-6 mx-auto mb-1" />
<div className="text-2xl font-bold">{report.year}</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-[#422268] mb-2">
{report.title}
</h3>
<div className="flex items-center gap-3">
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => window.open(report.document_url, '_blank')}
className="text-[#664fa3] hover:text-[#533a82]"
>
<ExternalLink className="h-4 w-4 mr-1" />
View
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(report)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(report)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selectedReport ? 'Edit Financial Report' : 'Add Financial Report'}
</DialogTitle>
<DialogDescription>
{selectedReport ? 'Update financial report information' : 'Add a new financial report'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="year">Fiscal Year *</Label>
<Input
id="year"
type="number"
value={formData.year}
onChange={(e) => setFormData({ ...formData, year: parseInt(e.target.value) })}
placeholder="2024"
required
min="2000"
max="2100"
/>
</div>
<div>
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="2024 Annual Financial Report"
required
/>
</div>
<div>
<Label htmlFor="document_type">Document Type *</Label>
<Select
value={formData.document_type}
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google_drive">Google Drive</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="upload">Upload</SelectItem>
</SelectContent>
</Select>
</div>
{formData.document_type === 'upload' ? (
<div>
<Label htmlFor="document_file">Upload PDF File *</Label>
<Input
id="document_file"
type="file"
accept=".pdf"
onChange={(e) => setUploadedFile(e.target.files[0])}
required={!selectedReport}
/>
{uploadedFile && (
<p className="text-sm text-[#664fa3] mt-1">
Selected: {uploadedFile.name}
</p>
)}
</div>
) : (
<div>
<Label htmlFor="document_url">Document URL *</Label>
<Input
id="document_url"
value={formData.document_url}
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
placeholder="https://drive.google.com/file/d/..."
required
/>
<p className="text-sm text-[#664fa3] mt-1">
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
</p>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
className="bg-[#664fa3] text-white"
disabled={submitting}
>
{submitting ? 'Saving...' : selectedReport ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Financial Report</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{reportToDelete?.title}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
className="bg-red-500 hover:bg-red-600"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default AdminFinancials;

View File

@@ -0,0 +1,354 @@
import React, { useState, useEffect, useRef } from 'react';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Textarea } from '../../components/ui/textarea';
import { Badge } from '../../components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react';
import { toast } from 'sonner';
import moment from 'moment';
const AdminGallery = () => {
const [events, setEvents] = useState([]);
const [selectedEvent, setSelectedEvent] = useState(null);
const [galleryImages, setGalleryImages] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [editingCaption, setEditingCaption] = useState(null);
const [newCaption, setNewCaption] = useState('');
const [maxFileSize, setMaxFileSize] = useState(5242880); // Default 5MB
const fileInputRef = useRef(null);
useEffect(() => {
fetchEvents();
fetchConfigLimits();
}, []);
useEffect(() => {
if (selectedEvent) {
fetchGallery(selectedEvent);
}
}, [selectedEvent]);
const fetchEvents = async () => {
try {
const response = await api.get('/admin/events');
setEvents(response.data);
} catch (error) {
console.error('Failed to fetch events:', error);
toast.error('Failed to load events');
} finally {
setLoading(false);
}
};
const fetchGallery = async (eventId) => {
try {
const response = await api.get(`/events/${eventId}/gallery`);
setGalleryImages(response.data);
} catch (error) {
console.error('Failed to fetch gallery:', error);
toast.error('Failed to load gallery');
}
};
const fetchConfigLimits = async () => {
try {
const response = await api.get('/config/limits');
setMaxFileSize(response.data.max_file_size_bytes);
} catch (error) {
console.error('Failed to fetch config limits:', error);
// Keep default value (5MB) if fetch fails
}
};
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B';
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
else return (bytes / 1048576).toFixed(1) + ' MB';
};
const handleFileSelect = async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
setUploading(true);
try {
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
await api.post(`/admin/events/${selectedEvent}/gallery`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
}
toast.success(`${files.length} ${files.length === 1 ? 'image' : 'images'} uploaded successfully`);
await fetchGallery(selectedEvent);
} catch (error) {
console.error('Failed to upload images:', error);
toast.error(error.response?.data?.detail || 'Failed to upload images');
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleDeleteImage = async (imageId) => {
if (!window.confirm('Are you sure you want to delete this image?')) {
return;
}
try {
await api.delete(`/admin/event-gallery/${imageId}`);
toast.success('Image deleted successfully');
await fetchGallery(selectedEvent);
} catch (error) {
console.error('Failed to delete image:', error);
toast.error('Failed to delete image');
}
};
const handleUpdateCaption = async () => {
if (!editingCaption) return;
try {
await api.put(`/admin/event-gallery/${editingCaption.id}`, null, {
params: { caption: newCaption }
});
toast.success('Caption updated successfully');
setEditingCaption(null);
setNewCaption('');
await fetchGallery(selectedEvent);
} catch (error) {
console.error('Failed to update caption:', error);
toast.error('Failed to update caption');
}
};
const openEditCaption = (image) => {
setEditingCaption(image);
setNewCaption(image.caption || '');
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Gallery Management
</h1>
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload and manage photos for event galleries
</p>
</div>
{/* Event Selection */}
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
<div className="space-y-4">
<div>
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Select Event
</Label>
<Select value={selectedEvent || ''} onValueChange={setSelectedEvent}>
<SelectTrigger className="border-[#ddd8eb] rounded-xl">
<SelectValue placeholder="Choose an event..." />
</SelectTrigger>
<SelectContent>
{events.map((event) => (
<SelectItem key={event.id} value={event.id}>
{event.title} - {moment(event.start_at).format('MMM D, YYYY')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedEvent && (
<div className="pt-4">
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*"
multiple
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{uploading ? (
<>
<Upload className="h-4 w-4 mr-2 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Upload Images
</>
)}
</Button>
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You can select multiple images. Max {formatFileSize(maxFileSize)} per image.
</p>
</div>
)}
</div>
</Card>
{/* Gallery Grid */}
{selectedEvent && (
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Gallery Images
</h2>
<Badge className="bg-[#664fa3] text-white px-3 py-1">
{galleryImages.length} {galleryImages.length === 1 ? 'image' : 'images'}
</Badge>
</div>
{galleryImages.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{galleryImages.map((image) => (
<div key={image.id} className="relative group">
<div className="aspect-square rounded-xl overflow-hidden bg-[#F8F7FB]">
<img
src={image.image_url}
alt={image.caption || 'Gallery image'}
className="w-full h-full object-cover"
/>
</div>
{/* Overlay with Actions */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
<Button
onClick={() => openEditCaption(image)}
size="sm"
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Edit className="h-4 w-4 mr-1" />
Caption
</Button>
<Button
onClick={() => handleDeleteImage(image.id)}
size="sm"
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</div>
{/* Caption Preview */}
{image.caption && (
<div className="mt-2">
<p className="text-sm text-[#664fa3] line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{image.caption}
</p>
</div>
)}
{/* File Size */}
<div className="mt-1">
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatFileSize(image.file_size_bytes)}
</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-16">
<ImageIcon className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Images Yet
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload images to create a gallery for this event.
</p>
</div>
)}
</Card>
)}
{/* Edit Caption Dialog */}
<Dialog open={!!editingCaption} onOpenChange={() => setEditingCaption(null)}>
<DialogContent className="bg-white sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Edit Image Caption
</DialogTitle>
</DialogHeader>
{editingCaption && (
<div className="space-y-4">
<div className="aspect-video rounded-xl overflow-hidden bg-[#F8F7FB]">
<img
src={editingCaption.image_url}
alt="Preview"
className="w-full h-full object-contain"
/>
</div>
<div>
<Label className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Caption
</Label>
<Textarea
value={newCaption}
onChange={(e) => setNewCaption(e.target.value)}
placeholder="Add a caption for this image..."
rows={3}
className="border-[#ddd8eb] rounded-xl mt-2"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => setEditingCaption(null)}
variant="outline"
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Cancel
</Button>
<Button
onClick={handleUpdateCaption}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Save Caption
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default AdminGallery;

View File

@@ -0,0 +1,422 @@
import React, { useEffect, useState } from 'react';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Textarea } from '../../components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import { toast } from 'sonner';
import {
FileText,
Plus,
Edit,
Trash2,
Calendar,
ExternalLink
} from 'lucide-react';
const AdminNewsletters = () => {
const [newsletters, setNewsletters] = useState([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedNewsletter, setSelectedNewsletter] = useState(null);
const [newsletterToDelete, setNewsletterToDelete] = useState(null);
const [uploadedFile, setUploadedFile] = useState(null);
const [formData, setFormData] = useState({
title: '',
description: '',
published_date: '',
document_url: '',
document_type: 'google_docs'
});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetchNewsletters();
}, []);
const fetchNewsletters = async () => {
try {
const response = await api.get('/newsletters');
setNewsletters(response.data);
} catch (error) {
toast.error('Failed to fetch newsletters');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setSelectedNewsletter(null);
setFormData({
title: '',
description: '',
published_date: new Date().toISOString().split('T')[0],
document_url: '',
document_type: 'google_docs'
});
setDialogOpen(true);
};
const handleEdit = (newsletter) => {
setSelectedNewsletter(newsletter);
setFormData({
title: newsletter.title,
description: newsletter.description || '',
published_date: new Date(newsletter.published_date).toISOString().split('T')[0],
document_url: newsletter.document_url,
document_type: newsletter.document_type
});
setDialogOpen(true);
};
const handleDelete = (newsletter) => {
setNewsletterToDelete(newsletter);
setDeleteDialogOpen(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
try {
const formDataToSend = new FormData();
formDataToSend.append('title', formData.title);
formDataToSend.append('description', formData.description);
formDataToSend.append('published_date', new Date(formData.published_date).toISOString());
formDataToSend.append('document_type', formData.document_type);
// Handle file upload or URL based on document_type
if (formData.document_type === 'upload') {
if (!uploadedFile && !selectedNewsletter) {
toast.error('Please select a file to upload');
setSubmitting(false);
return;
}
if (uploadedFile) {
formDataToSend.append('file', uploadedFile);
}
} else {
formDataToSend.append('document_url', formData.document_url);
}
if (selectedNewsletter) {
await api.put(`/admin/newsletters/${selectedNewsletter.id}`, formDataToSend);
toast.success('Newsletter updated successfully');
} else {
await api.post('/admin/newsletters', formDataToSend);
toast.success('Newsletter created successfully');
}
setDialogOpen(false);
setUploadedFile(null);
fetchNewsletters();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save newsletter');
} finally {
setSubmitting(false);
}
};
const confirmDelete = async () => {
try {
await api.delete(`/admin/newsletters/${newsletterToDelete.id}`);
toast.success('Newsletter deleted successfully');
setDeleteDialogOpen(false);
fetchNewsletters();
} catch (error) {
toast.error('Failed to delete newsletter');
}
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const groupByYear = (newsletters) => {
const grouped = {};
newsletters.forEach(newsletter => {
const year = new Date(newsletter.published_date).getFullYear();
if (!grouped[year]) {
grouped[year] = [];
}
grouped[year].push(newsletter);
});
return grouped;
};
const groupedNewsletters = groupByYear(newsletters);
const sortedYears = Object.keys(groupedNewsletters).sort((a, b) => b - a);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#664fa3]">Loading newsletters...</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Newsletter Management
</h1>
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create and manage newsletter archive
</p>
</div>
<Button
onClick={handleCreate}
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Newsletter
</Button>
</div>
{/* Newsletters List */}
{newsletters.length === 0 ? (
<Card className="p-12 text-center">
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create First Newsletter
</Button>
</Card>
) : (
<div className="space-y-6">
{sortedYears.map(year => (
<div key={year}>
<h2 className="text-xl font-semibold text-[#422268] mb-4 flex items-center gap-2">
<Calendar className="h-5 w-5" />
{year}
</h2>
<div className="grid gap-4">
{groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-[#422268] mb-2">
{newsletter.title}
</h3>
{newsletter.description && (
<p className="text-[#664fa3] mb-3">{newsletter.description}</p>
)}
<div className="flex items-center gap-3">
<Badge className="bg-[#DDD8EB] text-[#422268]">
{formatDate(newsletter.published_date)}
</Badge>
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
{newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => window.open(newsletter.document_url, '_blank')}
className="text-[#664fa3] hover:text-[#533a82]"
>
<ExternalLink className="h-4 w-4 mr-1" />
View
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(newsletter)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(newsletter)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
))}
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{selectedNewsletter ? 'Edit Newsletter' : 'Add Newsletter'}
</DialogTitle>
<DialogDescription>
{selectedNewsletter ? 'Update newsletter information' : 'Add a new newsletter to the archive'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="January 2025 Newsletter"
required
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Monthly community updates and announcements"
rows={3}
/>
</div>
<div>
<Label htmlFor="published_date">Published Date *</Label>
<Input
id="published_date"
type="date"
value={formData.published_date}
onChange={(e) => setFormData({ ...formData, published_date: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="document_type">Document Type *</Label>
<Select
value={formData.document_type}
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google_docs">Google Docs</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="upload">Upload</SelectItem>
</SelectContent>
</Select>
</div>
{formData.document_type === 'upload' ? (
<div>
<Label htmlFor="document_file">Upload PDF File *</Label>
<Input
id="document_file"
type="file"
accept=".pdf"
onChange={(e) => setUploadedFile(e.target.files[0])}
required={!selectedNewsletter}
/>
{uploadedFile && (
<p className="text-sm text-[#664fa3] mt-1">
Selected: {uploadedFile.name}
</p>
)}
</div>
) : (
<div>
<Label htmlFor="document_url">Document URL *</Label>
<Input
id="document_url"
value={formData.document_url}
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
placeholder="https://docs.google.com/document/d/..."
required
/>
<p className="text-sm text-[#664fa3] mt-1">
{formData.document_type === 'google_docs' && 'Paste the shareable link to your Google Doc'}
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
</p>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
className="bg-[#664fa3] text-white"
disabled={submitting}
>
{submitting ? 'Saving...' : selectedNewsletter ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Newsletter</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{newsletterToDelete?.title}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
className="bg-red-500 hover:bg-red-600"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default AdminNewsletters;

194
src/pages/members/Bylaws.js Normal file
View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { toast } from 'sonner';
import { Scale, ExternalLink, History, Check } from 'lucide-react';
export default function Bylaws() {
const [currentBylaws, setCurrentBylaws] = useState(null);
const [history, setHistory] = useState([]);
const [showHistory, setShowHistory] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCurrentBylaws();
fetchHistory();
}, []);
const fetchCurrentBylaws = async () => {
try {
const response = await api.get('/bylaws/current');
setCurrentBylaws(response.data);
} catch (error) {
if (error.response?.status !== 404) {
toast.error('Failed to load current bylaws');
}
} finally {
setLoading(false);
}
};
const fetchHistory = async () => {
try {
const response = await api.get('/bylaws/history');
setHistory(response.data);
} catch (error) {
console.error('Failed to load bylaws history');
}
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
if (loading) {
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading bylaws...
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-5xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
LOAF Bylaws
</h1>
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Review the official governing bylaws and policies of the LOAF community.
</p>
</div>
{/* Current Bylaws */}
{currentBylaws ? (
<Card className="p-8 bg-white rounded-2xl border-2 border-[#664fa3] mb-6">
<div className="flex items-start gap-4 mb-6">
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-4 rounded-xl">
<Scale className="h-8 w-8 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{currentBylaws.title}
</h2>
<Badge className="bg-[#81B29A] text-white">
<Check className="h-3 w-3 mr-1" />
Current Version
</Badge>
</div>
<div className="flex items-center gap-4 text-[#664fa3] mb-4">
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Version: <strong>{currentBylaws.version}</strong>
</span>
<span></span>
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Effective: <strong>{formatDate(currentBylaws.effective_date)}</strong>
</span>
</div>
<Button
onClick={() => window.open(currentBylaws.document_url, '_blank')}
size="lg"
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
>
<ExternalLink className="h-5 w-5" />
View Current Bylaws
</Button>
</div>
</div>
</Card>
) : (
<Card className="p-12 text-center bg-white rounded-2xl border border-[#ddd8eb] mb-6">
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No current bylaws document available
</p>
</Card>
)}
{/* Version History Toggle */}
{history.length > 1 && (
<div className="mb-6">
<Button
onClick={() => setShowHistory(!showHistory)}
variant="outline"
className="w-full border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9] rounded-full flex items-center justify-center gap-2"
>
<History className="h-4 w-4" />
{showHistory ? 'Hide' : 'View'} Version History ({history.length - 1} previous {history.length - 1 === 1 ? 'version' : 'versions'})
</Button>
</div>
)}
{/* Version History */}
{showHistory && history.length > 1 && (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Previous Versions
</h3>
{history.filter(b => !b.is_current).map(bylaws => (
<Card key={bylaws.id} className="p-6 bg-[#f9f7fc] rounded-xl border border-[#ddd8eb]">
<div className="flex items-center justify-between">
<div>
<h4 className="text-lg font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{bylaws.title}
</h4>
<div className="flex items-center gap-3 text-sm text-[#664fa3]">
<span>Version {bylaws.version}</span>
<span></span>
<span>Effective {formatDate(bylaws.effective_date)}</span>
</div>
</div>
<Button
onClick={() => window.open(bylaws.document_url, '_blank')}
variant="outline"
size="sm"
className="border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
View
</Button>
</div>
</Card>
))}
</div>
)}
{/* Information Card */}
<Card className="mt-8 p-6 bg-[#f9f7fc] border border-[#ddd8eb]">
<div className="flex items-start gap-3">
<Scale className="h-5 w-5 text-[#664fa3] mt-1" />
<div>
<h4 className="font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
About LOAF Bylaws
</h4>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
The bylaws serve as the governing document for LOAF, outlining the organization's structure,
membership requirements, officer responsibilities, and operational procedures. All members are
encouraged to familiarize themselves with these guidelines.
</p>
</div>
</div>
</Card>
</div>
<MemberFooter />
</div>
);
}

View File

@@ -0,0 +1,349 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Dialog, DialogContent } from '../../components/ui/dialog';
import { Badge } from '../../components/ui/badge';
import { Image as ImageIcon, Calendar, MapPin, ArrowLeft, X, ChevronLeft, ChevronRight } from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
import moment from 'moment';
const EventGallery = () => {
const { eventId } = useParams();
const navigate = useNavigate();
const [events, setEvents] = useState([]);
const [selectedEvent, setSelectedEvent] = useState(null);
const [galleryImages, setGalleryImages] = useState([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(null);
const [loading, setLoading] = useState(true);
const [galleryLoading, setGalleryLoading] = useState(false);
const { toast } = useToast();
useEffect(() => {
fetchEventsWithGalleries();
}, []);
useEffect(() => {
if (eventId) {
fetchEventGallery(eventId);
}
}, [eventId]);
const fetchEventsWithGalleries = async () => {
try {
const response = await api.get('/members/gallery');
setEvents(response.data);
// If there's an eventId in URL, find that event
if (eventId) {
const event = response.data.find(e => e.id === eventId);
if (event) {
setSelectedEvent(event);
}
}
} catch (error) {
console.error('Failed to fetch events:', error);
toast({
title: "Error",
description: "Failed to load event galleries. Please try again.",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const fetchEventGallery = async (id) => {
setGalleryLoading(true);
try {
const response = await api.get(`/events/${id}/gallery`);
setGalleryImages(response.data);
} catch (error) {
console.error('Failed to fetch gallery:', error);
toast({
title: "Error",
description: "Failed to load gallery images. Please try again.",
variant: "destructive"
});
} finally {
setGalleryLoading(false);
}
};
const handleEventClick = (event) => {
setSelectedEvent(event);
navigate(`/members/gallery/${event.id}`);
fetchEventGallery(event.id);
};
const handleBackToEvents = () => {
setSelectedEvent(null);
setGalleryImages([]);
navigate('/members/gallery');
};
const openLightbox = (index) => {
setSelectedImageIndex(index);
};
const closeLightbox = () => {
setSelectedImageIndex(null);
};
const nextImage = () => {
if (selectedImageIndex !== null) {
setSelectedImageIndex((selectedImageIndex + 1) % galleryImages.length);
}
};
const previousImage = () => {
if (selectedImageIndex !== null) {
setSelectedImageIndex((selectedImageIndex - 1 + galleryImages.length) % galleryImages.length);
}
};
const EventCard = ({ event }) => (
<Card
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer h-full"
onClick={() => handleEventClick(event)}
>
{/* Thumbnail */}
<div className="relative h-48 mb-4 rounded-xl overflow-hidden bg-[#F8F7FB]">
{event.thumbnail_url ? (
<img
src={event.thumbnail_url}
alt={event.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="h-16 w-16 text-[#ddd8eb]" />
</div>
)}
<div className="absolute top-3 right-3">
<Badge className="bg-[#664fa3] text-white px-3 py-1 rounded-full">
{event.gallery_count} {event.gallery_count === 1 ? 'photo' : 'photos'}
</Badge>
</div>
</div>
{/* Event Info */}
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{event.title}
</h3>
{event.description && (
<p className="text-[#664fa3] mb-3 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{event.description}
</p>
)}
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-[#664fa3]">
<Calendar className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{moment(event.start_at).format('MMMM D, YYYY')}
</span>
</div>
<div className="flex items-center gap-2 text-[#664fa3]">
<MapPin className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span>
</div>
</div>
</Card>
);
// Event Gallery Grid View
if (!selectedEvent) {
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Gallery
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Browse photos from past LOAF events.
</p>
</div>
{/* Events Grid */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading galleries...</p>
</div>
) : events.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{events.map((event) => (
<EventCard key={event.id} event={event} />
))}
</div>
) : (
<div className="text-center py-20">
<ImageIcon className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Event Galleries Yet
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Event photos will appear here once admins upload them.
</p>
</div>
)}
</div>
</div>
);
}
// Individual Event Gallery View
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Back Button */}
<Button
onClick={handleBackToEvents}
variant="ghost"
className="mb-6 text-[#664fa3] hover:text-[#422268] hover:bg-[#F8F7FB]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to All Galleries
</Button>
{/* Event Header */}
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedEvent.title}
</h1>
<div className="flex flex-wrap gap-4 text-[#664fa3]">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{moment(selectedEvent.start_at).format('MMMM D, YYYY')}
</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{selectedEvent.location}</span>
</div>
<Badge className="bg-[#664fa3] text-white px-3 py-1 rounded-full">
{selectedEvent.gallery_count} {selectedEvent.gallery_count === 1 ? 'photo' : 'photos'}
</Badge>
</div>
</div>
{/* Gallery Grid */}
{galleryLoading ? (
<div className="text-center py-20">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading images...</p>
</div>
) : galleryImages.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{galleryImages.map((image, index) => (
<div
key={image.id}
className="relative aspect-square rounded-xl overflow-hidden cursor-pointer group"
onClick={() => openLightbox(index)}
>
<img
src={image.image_url}
alt={image.caption || `Gallery image ${index + 1}`}
className="w-full h-full object-cover transition-transform group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<ImageIcon className="h-8 w-8 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{image.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-white text-sm line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{image.caption}
</p>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-20">
<ImageIcon className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Photos Yet
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Photos from this event will appear here once uploaded.
</p>
</div>
)}
</div>
{/* Lightbox Modal */}
<Dialog open={selectedImageIndex !== null} onOpenChange={closeLightbox}>
<DialogContent className="max-w-7xl w-full h-[90vh] p-0 bg-black border-0">
{selectedImageIndex !== null && galleryImages[selectedImageIndex] && (
<div className="relative w-full h-full flex items-center justify-center">
{/* Close Button */}
<Button
onClick={closeLightbox}
variant="ghost"
className="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/70 text-white rounded-full p-2"
>
<X className="h-6 w-6" />
</Button>
{/* Previous Button */}
{galleryImages.length > 1 && (
<Button
onClick={previousImage}
variant="ghost"
className="absolute left-4 z-50 bg-black/50 hover:bg-black/70 text-white rounded-full p-3"
>
<ChevronLeft className="h-8 w-8" />
</Button>
)}
{/* Image */}
<div className="w-full h-full flex flex-col items-center justify-center p-8">
<img
src={galleryImages[selectedImageIndex].image_url}
alt={galleryImages[selectedImageIndex].caption || `Image ${selectedImageIndex + 1}`}
className="max-w-full max-h-full object-contain"
/>
{galleryImages[selectedImageIndex].caption && (
<div className="mt-4 max-w-2xl">
<p className="text-white text-center text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{galleryImages[selectedImageIndex].caption}
</p>
</div>
)}
<div className="mt-2 text-white/70 text-sm">
{selectedImageIndex + 1} / {galleryImages.length}
</div>
</div>
{/* Next Button */}
{galleryImages.length > 1 && (
<Button
onClick={nextImage}
variant="ghost"
className="absolute right-4 z-50 bg-black/50 hover:bg-black/70 text-white rounded-full p-3"
>
<ChevronRight className="h-8 w-8" />
</Button>
)}
</div>
)}
</DialogContent>
</Dialog>
<MemberFooter />
</div>
);
};
export default EventGallery;

View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { toast } from 'sonner';
import { DollarSign, ExternalLink, TrendingUp } from 'lucide-react';
export default function Financials() {
const [reports, setReports] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchReports();
}, []);
const fetchReports = async () => {
try {
const response = await api.get('/financials');
setReports(response.data);
} catch (error) {
toast.error('Failed to load financial reports');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading financial reports...
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-5xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Financial Reports
</h1>
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Access annual financial reports and stay informed about LOAF's fiscal responsibility.
</p>
</div>
{/* Reports List */}
{reports.length === 0 ? (
<Card className="p-12 text-center bg-white rounded-2xl border border-[#ddd8eb]">
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No financial reports available yet
</p>
</Card>
) : (
<div className="space-y-6">
{reports.map(report => (
<Card key={report.id} className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-shadow">
<div className="flex items-center gap-6">
{/* Year Badge */}
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-6 rounded-xl text-white min-w-[120px] text-center">
<DollarSign className="h-8 w-8 mx-auto mb-2" />
<div className="text-3xl font-bold" style={{ fontFamily: "'Inter', sans-serif" }}>
{report.year}
</div>
<div className="text-sm opacity-90">Fiscal Year</div>
</div>
{/* Report Details */}
<div className="flex-1">
<h3 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{report.title}
</h3>
<div className="flex items-center gap-2 mb-4">
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
</Badge>
</div>
<Button
onClick={() => window.open(report.document_url, '_blank')}
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
View Report
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Transparency Note */}
{reports.length > 0 && (
<Card className="mt-8 p-6 bg-[#f9f7fc] border border-[#ddd8eb]">
<div className="flex items-start gap-3">
<TrendingUp className="h-5 w-5 text-[#664fa3] mt-1" />
<div>
<h4 className="font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Transparency & Accountability
</h4>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
LOAF is committed to financial transparency. These reports provide detailed information about our
revenue, expenses, and how member contributions support our community programs and operations.
</p>
</div>
</div>
</Card>
)}
</div>
<MemberFooter />
</div>
);
}

View File

@@ -0,0 +1,384 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Calendar, momentLocalizer } from 'react-big-calendar';
import moment from 'moment';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import AddToCalendarButton from '../../components/AddToCalendarButton';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
import { Calendar as CalendarIcon, MapPin, Users, Clock, X, Check, HelpCircle } from 'lucide-react';
const localizer = momentLocalizer(moment);
export default function MemberCalendar() {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedEvent, setSelectedEvent] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [rsvpLoading, setRsvpLoading] = useState(false);
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
const response = await api.get('/events');
setEvents(response.data);
} catch (error) {
toast.error('Failed to load events');
} finally {
setLoading(false);
}
};
// Transform events for react-big-calendar
const calendarEvents = useMemo(() => {
return events.map(event => ({
id: event.id,
title: event.title,
start: new Date(event.start_at),
end: new Date(event.end_at),
resource: event,
}));
}, [events]);
const handleSelectEvent = (event) => {
setSelectedEvent(event.resource);
setIsDialogOpen(true);
};
const handleRSVP = async (status) => {
if (!selectedEvent) return;
setRsvpLoading(true);
try {
await api.post(`/events/${selectedEvent.id}/rsvp`, { rsvp_status: status });
toast.success(`RSVP updated to: ${status}`);
await fetchEvents();
const updatedEvent = events.find(e => e.id === selectedEvent.id);
if (updatedEvent) {
setSelectedEvent({ ...updatedEvent, user_rsvp_status: status });
}
} catch (error) {
toast.error('Failed to update RSVP');
} finally {
setRsvpLoading(false);
}
};
const eventStyleGetter = (event) => {
const rsvpStatus = event.resource?.user_rsvp_status;
let backgroundColor = '#DDD8EB';
let borderColor = '#664fa3';
if (rsvpStatus === 'yes') {
backgroundColor = '#81B29A';
borderColor = '#66927e';
} else if (rsvpStatus === 'no') {
backgroundColor = '#9ca3af';
borderColor = '#6b7280';
} else if (rsvpStatus === 'maybe') {
backgroundColor = '#fb923c';
borderColor = '#ea580c';
}
return {
style: {
backgroundColor,
borderColor,
borderWidth: '2px',
borderStyle: 'solid',
borderRadius: '6px',
color: 'white',
fontWeight: '500',
fontSize: '0.875rem',
padding: '2px 6px',
}
};
};
if (loading) {
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading calendar...
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="mb-8">
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Calendar
</h1>
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View and manage your event RSVPs. Click on any event to see details and update your RSVP.
</p>
<div className="flex gap-3 flex-wrap items-center">
<AddToCalendarButton
showSubscribe={true}
variant="default"
/>
<div className="flex gap-4 ml-auto">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-[#81B29A]"></div>
<span className="text-sm text-[#664fa3]">Going</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-[#fb923c]"></div>
<span className="text-sm text-[#664fa3]">Maybe</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-[#9ca3af]"></div>
<span className="text-sm text-[#664fa3]">Not Going</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-[#DDD8EB]"></div>
<span className="text-sm text-[#664fa3]">No RSVP</span>
</div>
</div>
</div>
</div>
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
<Calendar
localizer={localizer}
events={calendarEvents}
startAccessor="start"
endAccessor="end"
style={{ height: 700 }}
onSelectEvent={handleSelectEvent}
eventPropGetter={eventStyleGetter}
views={['month', 'week', 'day', 'agenda']}
defaultView="month"
popup
className="member-calendar"
/>
</Card>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
{selectedEvent && (
<>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
<CalendarIcon className="h-6 w-6 text-[#664fa3]" />
</div>
{selectedEvent.user_rsvp_status && (
<Badge
className={`px-3 py-1 rounded-full text-sm ${
selectedEvent.user_rsvp_status === 'yes'
? 'bg-[#81B29A] text-white'
: selectedEvent.user_rsvp_status === 'no'
? 'bg-gray-400 text-white'
: 'bg-orange-400 text-white'
}`}
>
{selectedEvent.user_rsvp_status === 'yes' && 'Going'}
{selectedEvent.user_rsvp_status === 'no' && 'Not Going'}
{selectedEvent.user_rsvp_status === 'maybe' && 'Maybe'}
</Badge>
)}
</div>
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedEvent.title}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-3">
<div className="flex items-center gap-3 text-[#664fa3]">
<CalendarIcon className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(selectedEvent.start_at).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
</div>
<div className="flex items-center gap-3 text-[#664fa3]">
<Clock className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(selectedEvent.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - {new Date(selectedEvent.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex items-center gap-3 text-[#664fa3]">
<MapPin className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{selectedEvent.location}</span>
</div>
<div className="flex items-center gap-3 text-[#664fa3]">
<Users className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedEvent.rsvp_count || 0} {selectedEvent.rsvp_count === 1 ? 'person' : 'people'} attending
{selectedEvent.capacity && ` (Capacity: ${selectedEvent.capacity})`}
</span>
</div>
</div>
{selectedEvent.description && (
<div className="pt-4 border-t border-[#ddd8eb]">
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
About This Event
</h3>
<p className="text-[#664fa3] leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedEvent.description}
</p>
</div>
)}
<div className="pt-4 border-t border-[#ddd8eb]">
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Your RSVP
</h3>
<div className="flex gap-3 flex-wrap">
<Button
onClick={() => handleRSVP('yes')}
disabled={rsvpLoading}
size="sm"
className={`rounded-full px-6 flex items-center gap-2 ${
selectedEvent.user_rsvp_status === 'yes'
? 'bg-[#81B29A] text-white hover:bg-[#66927e]'
: 'bg-[#DDD8EB] text-[#422268] hover:bg-[#c4bed8]'
}`}
>
<Check className="h-4 w-4" />
I'm Going
</Button>
<Button
onClick={() => handleRSVP('maybe')}
disabled={rsvpLoading}
size="sm"
variant="outline"
className={`rounded-full px-6 flex items-center gap-2 border-2 ${
selectedEvent.user_rsvp_status === 'maybe'
? 'border-orange-400 bg-orange-100 text-orange-700'
: 'border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9]'
}`}
>
<HelpCircle className="h-4 w-4" />
Maybe
</Button>
<Button
onClick={() => handleRSVP('no')}
disabled={rsvpLoading}
size="sm"
variant="outline"
className={`rounded-full px-6 flex items-center gap-2 border-2 ${
selectedEvent.user_rsvp_status === 'no'
? 'border-gray-400 bg-gray-100 text-gray-700'
: 'border-gray-400 text-gray-600 hover:bg-gray-50'
}`}
>
<X className="h-4 w-4" />
Can't Attend
</Button>
</div>
</div>
<div className="pt-4 border-t border-[#ddd8eb]">
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Add to Your Calendar
</h3>
<AddToCalendarButton
event={selectedEvent}
showSubscribe={false}
variant="outline"
size="sm"
/>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
<style jsx global>{`
.member-calendar .rbc-header {
padding: 12px 6px;
font-family: 'Inter', sans-serif;
font-weight: 600;
color: #422268;
background-color: #f9f7fc;
border-bottom: 2px solid #ddd8eb;
}
.member-calendar .rbc-today {
background-color: #f1eef9;
}
.member-calendar .rbc-off-range-bg {
background-color: #fafafa;
}
.member-calendar .rbc-event {
border-radius: 6px;
padding: 2px 6px;
}
.member-calendar .rbc-event:hover {
opacity: 0.85;
cursor: pointer;
}
.member-calendar .rbc-toolbar button {
color: #664fa3;
border-color: #ddd8eb;
font-family: 'Nunito Sans', sans-serif;
}
.member-calendar .rbc-toolbar button:hover {
background-color: #f1eef9;
border-color: #664fa3;
}
.member-calendar .rbc-toolbar button.rbc-active {
background-color: #664fa3;
color: white;
}
.member-calendar .rbc-month-view {
border: 1px solid #ddd8eb;
border-radius: 8px;
}
.member-calendar .rbc-day-bg {
border-color: #ddd8eb;
}
.member-calendar .rbc-date-cell {
padding: 8px;
font-family: 'Nunito Sans', sans-serif;
}
`}</style>
<MemberFooter />
</div>
);
}

View File

@@ -0,0 +1,490 @@
import React, { useState, useEffect, useRef } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Textarea } from '../../components/ui/textarea';
import { Label } from '../../components/ui/label';
import { Switch } from '../../components/ui/switch';
import { User, Upload, X, Facebook, Instagram, Twitter, Linkedin, Camera, Save, Eye } from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
const MemberProfile = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [profile, setProfile] = useState(null);
const [formData, setFormData] = useState({
social_media_facebook: '',
social_media_instagram: '',
social_media_twitter: '',
social_media_linkedin: '',
show_in_directory: false,
directory_email: '',
directory_bio: '',
directory_address: '',
directory_phone: '',
directory_partner_name: ''
});
const [previewImage, setPreviewImage] = useState(null);
const fileInputRef = useRef(null);
const { toast } = useToast();
useEffect(() => {
fetchProfile();
}, []);
const fetchProfile = async () => {
try {
const response = await api.get('/members/profile');
setProfile(response.data);
// Populate form with existing data
setFormData({
social_media_facebook: response.data.social_media_facebook || '',
social_media_instagram: response.data.social_media_instagram || '',
social_media_twitter: response.data.social_media_twitter || '',
social_media_linkedin: response.data.social_media_linkedin || '',
show_in_directory: response.data.show_in_directory || false,
directory_email: response.data.directory_email || '',
directory_bio: response.data.directory_bio || '',
directory_address: response.data.directory_address || '',
directory_phone: response.data.directory_phone || '',
directory_partner_name: response.data.directory_partner_name || ''
});
if (response.data.profile_photo_url) {
setPreviewImage(response.data.profile_photo_url);
}
} catch (error) {
console.error('Failed to fetch profile:', error);
toast({
title: "Error",
description: "Failed to load profile. Please try again.",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleSwitchChange = (checked) => {
setFormData(prev => ({
...prev,
show_in_directory: checked
}));
};
const handlePhotoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast({
title: "Invalid File",
description: "Please upload an image file (JPG, PNG, WebP, or GIF).",
variant: "destructive"
});
return;
}
// Validate file size (50MB max)
if (file.size > 52428800) {
toast({
title: "File Too Large",
description: "Image must be smaller than 50MB.",
variant: "destructive"
});
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/members/profile/upload-photo', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
setPreviewImage(response.data.profile_photo_url);
toast({
title: "Success",
description: "Profile photo uploaded successfully!"
});
// Refresh profile
await fetchProfile();
} catch (error) {
console.error('Failed to upload photo:', error);
toast({
title: "Upload Failed",
description: error.response?.data?.detail || "Failed to upload photo. Please try again.",
variant: "destructive"
});
} finally {
setUploading(false);
}
};
const handleDeletePhoto = async () => {
if (!window.confirm('Are you sure you want to delete your profile photo?')) {
return;
}
try {
await api.delete('/members/profile/delete-photo');
setPreviewImage(null);
toast({
title: "Success",
description: "Profile photo deleted successfully."
});
await fetchProfile();
} catch (error) {
console.error('Failed to delete photo:', error);
toast({
title: "Error",
description: "Failed to delete photo. Please try again.",
variant: "destructive"
});
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
await api.put('/members/profile', formData);
toast({
title: "Success",
description: "Profile updated successfully!"
});
await fetchProfile();
} catch (error) {
console.error('Failed to update profile:', error);
toast({
title: "Error",
description: "Failed to update profile. Please try again.",
variant: "destructive"
});
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="text-center py-20">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-4xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Member Profile
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Enhance your profile with a photo and social media links.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Profile Photo Section */}
<Card className="p-8 bg-white border-[#ddd8eb] rounded-2xl">
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
Profile Photo
</h2>
<div className="flex flex-col md:flex-row items-center gap-8">
<div className="relative">
{previewImage ? (
<div className="relative">
<img
src={previewImage}
alt="Profile"
className="w-40 h-40 rounded-full object-cover border-4 border-[#ddd8eb]"
/>
<Button
type="button"
onClick={handleDeletePhoto}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-2 w-8 h-8"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="w-40 h-40 rounded-full bg-[#F8F7FB] border-4 border-[#ddd8eb] flex items-center justify-center">
<User className="h-20 w-20 text-[#ddd8eb]" />
</div>
)}
</div>
<div className="flex-1">
<input
type="file"
ref={fileInputRef}
onChange={handlePhotoUpload}
accept="image/*"
className="hidden"
/>
<Button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl px-6 py-3 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{uploading ? (
<>
<Upload className="h-5 w-5 animate-spin" />
Uploading...
</>
) : (
<>
<Camera className="h-5 w-5" />
{previewImage ? 'Change Photo' : 'Upload Photo'}
</>
)}
</Button>
<p className="text-sm text-[#664fa3] mt-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
JPG, PNG, WebP, or GIF. Max 50MB.
</p>
</div>
</div>
</Card>
{/* Social Media Section */}
<Card className="p-8 bg-white border-[#ddd8eb] rounded-2xl">
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
Social Media Links
</h2>
<div className="space-y-6">
<div>
<Label className="flex items-center gap-2 text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Facebook className="h-4 w-4 text-[#1877F2]" />
Facebook Profile URL
</Label>
<Input
type="url"
name="social_media_facebook"
value={formData.social_media_facebook}
onChange={handleInputChange}
placeholder="https://facebook.com/yourprofile"
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
<div>
<Label className="flex items-center gap-2 text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Instagram className="h-4 w-4 text-[#E4405F]" />
Instagram Profile URL
</Label>
<Input
type="url"
name="social_media_instagram"
value={formData.social_media_instagram}
onChange={handleInputChange}
placeholder="https://instagram.com/yourprofile"
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
<div>
<Label className="flex items-center gap-2 text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Twitter className="h-4 w-4 text-[#1DA1F2]" />
Twitter/X Profile URL
</Label>
<Input
type="url"
name="social_media_twitter"
value={formData.social_media_twitter}
onChange={handleInputChange}
placeholder="https://twitter.com/yourprofile"
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
<div>
<Label className="flex items-center gap-2 text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Linkedin className="h-4 w-4 text-[#0A66C2]" />
LinkedIn Profile URL
</Label>
<Input
type="url"
name="social_media_linkedin"
value={formData.social_media_linkedin}
onChange={handleInputChange}
placeholder="https://linkedin.com/in/yourprofile"
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
</div>
</Card>
{/* Directory Settings Section */}
<Card className="p-8 bg-white border-[#ddd8eb] rounded-2xl">
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
Directory Settings
</h2>
<div className="space-y-6">
<div className="flex items-center justify-between p-4 bg-[#F8F7FB] rounded-xl">
<div className="flex-1">
<Label className="text-[#422268] font-medium flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Eye className="h-4 w-4 text-[#664fa3]" />
Show in Members Directory
</Label>
<p className="text-sm text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Allow other members to see your profile in the directory
</p>
</div>
<Switch
checked={formData.show_in_directory}
onCheckedChange={handleSwitchChange}
className="data-[state=checked]:bg-[#664fa3]"
/>
</div>
{formData.show_in_directory && (
<>
<div>
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Directory Email (visible to members)
</Label>
<Input
type="email"
name="directory_email"
value={formData.directory_email}
onChange={handleInputChange}
placeholder="public.email@example.com"
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
<div>
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Bio (visible to members)
</Label>
<Textarea
name="directory_bio"
value={formData.directory_bio}
onChange={handleInputChange}
placeholder="Tell other members about yourself..."
rows={4}
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Directory Address (optional)
</Label>
<Input
type="text"
name="directory_address"
value={formData.directory_address}
onChange={handleInputChange}
placeholder="123 Main St, City, State"
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
<div>
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Directory Phone (optional)
</Label>
<Input
type="tel"
name="directory_phone"
value={formData.directory_phone}
onChange={handleInputChange}
placeholder="(555) 123-4567"
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
</div>
<div>
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Partner Name (if applicable)
</Label>
<Input
type="text"
name="directory_partner_name"
value={formData.directory_partner_name}
onChange={handleInputChange}
placeholder="Partner's name"
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
</>
)}
</div>
</Card>
{/* Submit Button */}
<div className="flex justify-end">
<Button
type="submit"
disabled={saving}
className="bg-[#ff9e77] hover:bg-[#ff8c5a] text-white rounded-xl px-8 py-3 text-lg"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{saving ? (
'Saving...'
) : (
<>
<Save className="h-5 w-5 mr-2" />
Save Profile
</>
)}
</Button>
</div>
</form>
</div>
<MemberFooter />
</div>
);
};
export default MemberProfile;

View File

@@ -0,0 +1,295 @@
import React, { useState, useEffect } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card';
import { Input } from '../../components/ui/input';
import { Badge } from '../../components/ui/badge';
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin } from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
const MembersDirectory = () => {
const [members, setMembers] = useState([]);
const [filteredMembers, setFilteredMembers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
fetchMembers();
}, []);
useEffect(() => {
filterMembers();
}, [searchQuery, members]);
const fetchMembers = async () => {
try {
const response = await api.get('/members/directory');
setMembers(response.data);
setFilteredMembers(response.data);
} catch (error) {
console.error('Failed to fetch members:', error);
toast({
title: "Error",
description: "Failed to load members directory. Please try again.",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const filterMembers = () => {
if (!searchQuery.trim()) {
setFilteredMembers(members);
return;
}
const query = searchQuery.toLowerCase();
const filtered = members.filter(member => {
const fullName = `${member.first_name} ${member.last_name}`.toLowerCase();
const bio = (member.directory_bio || '').toLowerCase();
return fullName.includes(query) || bio.includes(query);
});
setFilteredMembers(filtered);
};
const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
};
const getSocialMediaLink = (url) => {
if (!url) return null;
// Ensure URL has protocol
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return `https://${url}`;
}
return url;
};
const MemberCard = ({ member }) => (
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
{/* Profile Photo */}
<div className="flex justify-center mb-4">
{member.profile_photo_url ? (
<img
src={member.profile_photo_url}
alt={`${member.first_name} ${member.last_name}`}
className="w-32 h-32 rounded-full object-cover border-4 border-[#ddd8eb]"
/>
) : (
<div className="w-32 h-32 rounded-full bg-[#DDD8EB] border-4 border-[#ddd8eb] flex items-center justify-center">
<span className="text-4xl font-semibold text-[#664fa3]" style={{ fontFamily: "'Inter', sans-serif" }}>
{getInitials(member.first_name, member.last_name)}
</span>
</div>
)}
</div>
{/* Name */}
<h3 className="text-2xl font-semibold text-[#422268] text-center mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
{member.first_name} {member.last_name}
</h3>
{/* Partner Name */}
{member.directory_partner_name && (
<div className="flex items-center justify-center gap-2 mb-4">
<Heart className="h-4 w-4 text-[#ff9e77]" />
<span className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Partner: {member.directory_partner_name}
</span>
</div>
)}
{/* Bio */}
{member.directory_bio && (
<p className="text-[#664fa3] text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{member.directory_bio}
</p>
)}
{/* Contact Information */}
<div className="space-y-3 mb-4">
{member.directory_email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-[#664fa3] flex-shrink-0" />
<a
href={`mailto:${member.directory_email}`}
className="text-[#664fa3] hover:text-[#422268] truncate"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{member.directory_email}
</a>
</div>
)}
{member.directory_phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-[#664fa3] flex-shrink-0" />
<a
href={`tel:${member.directory_phone}`}
className="text-[#664fa3] hover:text-[#422268]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{member.directory_phone}
</a>
</div>
)}
{member.directory_address && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="h-4 w-4 text-[#664fa3] flex-shrink-0 mt-0.5" />
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{member.directory_address}
</span>
</div>
)}
</div>
{/* Social Media Links */}
{(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
<div className="pt-4 border-t border-[#ddd8eb]">
<div className="flex justify-center gap-3">
{member.social_media_facebook && (
<a
href={getSocialMediaLink(member.social_media_facebook)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Facebook"
>
<Facebook className="h-5 w-5 text-[#1877F2]" />
</a>
)}
{member.social_media_instagram && (
<a
href={getSocialMediaLink(member.social_media_instagram)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Instagram"
>
<Instagram className="h-5 w-5 text-[#E4405F]" />
</a>
)}
{member.social_media_twitter && (
<a
href={getSocialMediaLink(member.social_media_twitter)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Twitter/X"
>
<Twitter className="h-5 w-5 text-[#1DA1F2]" />
</a>
)}
{member.social_media_linkedin && (
<a
href={getSocialMediaLink(member.social_media_linkedin)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="LinkedIn"
>
<Linkedin className="h-5 w-5 text-[#0A66C2]" />
</a>
)}
</div>
</div>
)}
</Card>
);
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Directory
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Connect with fellow LOAF members in our community.
</p>
</div>
{/* Search Bar */}
<div className="mb-8">
<div className="relative max-w-xl">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
<Input
type="text"
placeholder="Search by name or bio..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 pr-4 py-6 text-lg border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
{searchQuery && (
<p className="mt-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
</p>
)}
</div>
{/* Members Grid */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading members...</p>
</div>
) : filteredMembers.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredMembers.map((member) => (
<MemberCard key={member.id} member={member} />
))}
</div>
) : (
<div className="text-center py-20">
<User className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
{searchQuery ? 'No Members Found' : 'No Members in Directory'}
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery
? 'Try adjusting your search query.'
: 'Members who opt in to the directory will appear here.'}
</p>
</div>
)}
{/* Info Card */}
{!loading && members.length > 0 && (
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
<div className="flex items-start gap-4">
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
<User className="h-6 w-6 text-[#664fa3]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Want to appear in the directory?
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '}
<a href="/members/profile" className="text-[#ff9e77] hover:underline font-medium">
Edit your profile
</a>
</p>
</div>
</div>
</Card>
)}
</div>
<MemberFooter />
</div>
);
};
export default MembersDirectory;

View File

@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { toast } from 'sonner';
import { FileText, ExternalLink, Calendar, Search } from 'lucide-react';
export default function NewsletterArchive() {
const [newsletters, setNewsletters] = useState([]);
const [years, setYears] = useState([]);
const [selectedYear, setSelectedYear] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchYears();
fetchNewsletters();
}, []);
const fetchYears = async () => {
try {
const response = await api.get('/newsletters/years');
setYears(response.data);
} catch (error) {
console.error('Failed to load years');
}
};
const fetchNewsletters = async (year = null) => {
try {
setLoading(true);
const url = year ? `/newsletters?year=${year}` : '/newsletters';
const response = await api.get(url);
setNewsletters(response.data);
} catch (error) {
toast.error('Failed to load newsletters');
} finally {
setLoading(false);
}
};
const handleYearFilter = (year) => {
setSelectedYear(year);
fetchNewsletters(year);
};
const clearFilter = () => {
setSelectedYear(null);
fetchNewsletters();
};
const filteredNewsletters = newsletters.filter(newsletter =>
newsletter.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(newsletter.description && newsletter.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const groupByYear = (newsletters) => {
const grouped = {};
newsletters.forEach(newsletter => {
const year = new Date(newsletter.published_date).getFullYear();
if (!grouped[year]) {
grouped[year] = [];
}
grouped[year].push(newsletter);
});
return grouped;
};
const groupedNewsletters = groupByYear(filteredNewsletters);
const sortedYears = Object.keys(groupedNewsletters).sort((a, b) => b - a);
if (loading) {
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading newsletters...
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Newsletter Archive
</h1>
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Browse past monthly newsletters and stay informed about LOAF community updates.
</p>
{/* Filters */}
<div className="flex gap-4 flex-wrap items-center">
{/* Search */}
<div className="relative flex-1 min-w-[300px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[#664fa3]" />
<Input
type="text"
placeholder="Search newsletters..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
{/* Year Filter */}
<div className="flex gap-2 flex-wrap">
<Button
onClick={clearFilter}
variant={selectedYear === null ? "default" : "outline"}
size="sm"
className={selectedYear === null ? "bg-[#664fa3] text-white" : "border-[#664fa3] text-[#664fa3]"}
>
All Years
</Button>
{years.map(year => (
<Button
key={year}
onClick={() => handleYearFilter(year)}
variant={selectedYear === year ? "default" : "outline"}
size="sm"
className={selectedYear === year ? "bg-[#664fa3] text-white" : "border-[#664fa3] text-[#664fa3]"}
>
{year}
</Button>
))}
</div>
</div>
</div>
{/* Newsletter List */}
{filteredNewsletters.length === 0 ? (
<Card className="p-12 text-center bg-white rounded-2xl border border-[#ddd8eb]">
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No newsletters found
</p>
</Card>
) : (
<div className="space-y-8">
{sortedYears.map(year => (
<div key={year}>
<h2 className="text-2xl font-semibold text-[#422268] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Calendar className="h-6 w-6" />
{year}
</h2>
<div className="grid md:grid-cols-2 gap-6">
{groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-shadow">
<div className="flex items-start gap-4">
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg flex-shrink-0">
<FileText className="h-6 w-6 text-[#664fa3]" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{newsletter.title}
</h3>
{newsletter.description && (
<p className="text-[#664fa3] mb-3 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{newsletter.description}
</p>
)}
<div className="flex items-center gap-3 mb-4">
<Badge className="bg-[#DDD8EB] text-[#422268] hover:bg-[#DDD8EB]">
{formatDate(newsletter.published_date)}
</Badge>
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
{newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
</Badge>
</div>
<Button
onClick={() => window.open(newsletter.document_url, '_blank')}
className="w-full bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center justify-center gap-2"
>
<ExternalLink className="h-4 w-4" />
View Newsletter
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
))}
</div>
)}
</div>
<MemberFooter />
</div>
);
}

158
yarn.lock
View File

@@ -1057,7 +1057,7 @@
"@babel/plugin-transform-modules-commonjs" "^7.27.1"
"@babel/plugin-transform-typescript" "^7.28.5"
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3":
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.16.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
@@ -1724,6 +1724,11 @@
schema-utils "^4.2.0"
source-map "^0.7.3"
"@popperjs/core@^2.11.6":
version "2.11.8"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@radix-ui/number@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090"
@@ -2344,6 +2349,13 @@
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
"@restart/hooks@^0.4.7":
version "0.4.16"
resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.16.tgz#95ae8ac1cc7e2bd4fed5e39800ff85604c6d59fb"
integrity sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==
dependencies:
dequal "^2.0.3"
"@rollup/plugin-babel@^5.2.0":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
@@ -2803,6 +2815,13 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/react@>=16.9.11":
version "19.2.7"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.7.tgz#84e62c0f23e8e4e5ac2cadcea1ffeacccae7f62f"
integrity sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==
dependencies:
csstype "^3.2.2"
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@@ -2876,6 +2895,11 @@
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
"@types/warning@^3.0.0":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.3.tgz#d1884c8cc4a426d1ac117ca2611bf333834c6798"
integrity sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==
"@types/ws@^8.5.5":
version "8.18.1"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
@@ -3957,6 +3981,11 @@ clone-deep@^4.0.1:
kind-of "^6.0.2"
shallow-clone "^3.0.0"
clsx@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
@@ -4395,6 +4424,11 @@ cssstyle@^2.3.0:
dependencies:
cssom "~0.3.6"
csstype@^3.0.2, csstype@^3.2.2:
version "3.2.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -4436,11 +4470,21 @@ data-view-byte-offset@^1.0.1:
es-errors "^1.3.0"
is-data-view "^1.0.1"
date-arithmetic@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/date-arithmetic/-/date-arithmetic-4.1.0.tgz#e5d6434e9deb71f79760a37b729e4a515e730ddf"
integrity sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==
date-fns@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
dayjs@^1.11.7:
version "1.11.19"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938"
integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==
debug@2.6.9, debug@^2.6.0:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -4527,6 +4571,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
dequal@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
destroy@1.2.0, destroy@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
@@ -4610,6 +4659,14 @@ dom-converter@^0.2.0:
dependencies:
utila "~0.4"
dom-helpers@^5.2.0, dom-helpers@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
dependencies:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
dom-serializer@0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
@@ -5855,6 +5912,11 @@ global-prefix@^3.0.0:
kind-of "^6.0.2"
which "^1.3.1"
globalize@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/globalize/-/globalize-0.1.1.tgz#4d04ba65a580a8b0bdcc9ed974aeb497b9c80a56"
integrity sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==
globals@15.15.0:
version "15.15.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-15.15.0.tgz#7c4761299d41c32b075715a4ce1ede7897ff72a8"
@@ -6226,6 +6288,13 @@ internal-slot@^1.1.0:
hasown "^2.0.2"
side-channel "^1.1.0"
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
dependencies:
loose-envify "^1.0.0"
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -7327,6 +7396,11 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
@@ -7357,7 +7431,7 @@ lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
loose-envify@^1.4.0:
loose-envify@^1.0.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -7383,6 +7457,11 @@ lucide-react@^0.507.0:
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.507.0.tgz#d93a75ed130bd530a368fe1dd4ea009ea90a772b"
integrity sha512-XfgE6gvAHwAtnbUvWiTTHx4S3VGR+cUJHEc0vrh9Ogu672I1Tue2+Cp/8JJqpytgcBHAB1FVI297W4XGNwc2dQ==
luxon@^3.2.1:
version "3.7.2"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.7.2.tgz#d697e48f478553cca187a0f8436aff468e3ba0ba"
integrity sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==
magic-string@^0.25.0, magic-string@^0.25.7:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
@@ -7443,6 +7522,11 @@ memfs@^3.1.2, memfs@^3.4.3:
dependencies:
fs-monkey "^1.0.4"
memoize-one@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==
merge-descriptors@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
@@ -7537,6 +7621,18 @@ mkdirp@~0.5.1:
dependencies:
minimist "^1.2.6"
moment-timezone@^0.5.40:
version "0.5.48"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.48.tgz#111727bb274734a518ae154b5ca589283f058967"
integrity sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==
dependencies:
moment "^2.29.4"
moment@^2.29.4, moment@^2.30.1:
version "2.30.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -8718,6 +8814,28 @@ react-app-polyfill@^3.0.0:
regenerator-runtime "^0.13.9"
whatwg-fetch "^3.6.2"
react-big-calendar@^1.19.4:
version "1.19.4"
resolved "https://registry.yarnpkg.com/react-big-calendar/-/react-big-calendar-1.19.4.tgz#a1f00f4cb817a7e210a8137bbabf5726c032d2da"
integrity sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA==
dependencies:
"@babel/runtime" "^7.20.7"
clsx "^1.2.1"
date-arithmetic "^4.1.0"
dayjs "^1.11.7"
dom-helpers "^5.2.1"
globalize "^0.1.1"
invariant "^2.2.4"
lodash "^4.17.21"
lodash-es "^4.17.21"
luxon "^3.2.1"
memoize-one "^6.0.0"
moment "^2.29.4"
moment-timezone "^0.5.40"
prop-types "^15.8.1"
react-overlays "^5.2.1"
uncontrollable "^7.2.1"
react-day-picker@8.10.1:
version "8.10.1"
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80"
@@ -8790,6 +8908,25 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-overlays@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-5.2.1.tgz#49dc007321adb6784e1f212403f0fb37a74ab86b"
integrity sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==
dependencies:
"@babel/runtime" "^7.13.8"
"@popperjs/core" "^2.11.6"
"@restart/hooks" "^0.4.7"
"@types/warning" "^3.0.0"
dom-helpers "^5.2.0"
prop-types "^15.7.2"
uncontrollable "^7.2.1"
warning "^4.0.3"
react-refresh@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"
@@ -10217,6 +10354,16 @@ unbox-primitive@^1.1.0:
has-symbols "^1.1.0"
which-boxed-primitive "^1.1.1"
uncontrollable@^7.2.1:
version "7.2.1"
resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.2.1.tgz#1fa70ba0c57a14d5f78905d533cf63916dc75738"
integrity sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==
dependencies:
"@babel/runtime" "^7.6.3"
"@types/react" ">=16.9.11"
invariant "^2.2.4"
react-lifecycles-compat "^3.0.4"
underscore@1.12.1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e"
@@ -10402,6 +10549,13 @@ walker@^1.0.7:
dependencies:
makeerror "1.0.12"
warning@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
dependencies:
loose-envify "^1.0.0"
watchpack@^2.4.4:
version "2.4.4"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.4.tgz#473bda72f0850453da6425081ea46fc0d7602947"