Update New Features
@@ -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
|
After Width: | Height: | Size: 16 KiB |
BIN
public/icon-active.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/icon-meet-greet.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/icon-socials.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -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
|
After Width: | Height: | Size: 35 KiB |
BIN
public/shooting-star.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/tagline-image.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
85
src/App.js
@@ -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>
|
||||
|
||||
216
src/components/AddToCalendarButton.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
122
src/components/MemberFooter.js
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
71
src/components/PublicFooter.js
Normal 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;
|
||||
108
src/components/PublicNavbar.js
Normal 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
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
477
src/pages/admin/AdminBylaws.js
Normal 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;
|
||||
374
src/pages/admin/AdminFinancials.js
Normal 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;
|
||||
354
src/pages/admin/AdminGallery.js
Normal 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;
|
||||
422
src/pages/admin/AdminNewsletters.js
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
349
src/pages/members/EventGallery.js
Normal 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;
|
||||
125
src/pages/members/Financials.js
Normal 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>
|
||||
);
|
||||
}
|
||||
384
src/pages/members/MemberCalendar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
490
src/pages/members/MemberProfile.js
Normal 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;
|
||||
295
src/pages/members/MembersDirectory.js
Normal 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;
|
||||
210
src/pages/members/NewsletterArchive.js
Normal 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
@@ -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"
|
||||
|
||||