diff --git a/package.json b/package.json index bc17824..aee0313 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/hero-loaf.png b/public/hero-loaf.png new file mode 100644 index 0000000..d203368 Binary files /dev/null and b/public/hero-loaf.png differ diff --git a/public/icon-active.png b/public/icon-active.png new file mode 100644 index 0000000..be7f320 Binary files /dev/null and b/public/icon-active.png differ diff --git a/public/icon-meet-greet.png b/public/icon-meet-greet.png new file mode 100644 index 0000000..a013978 Binary files /dev/null and b/public/icon-meet-greet.png differ diff --git a/public/icon-socials.png b/public/icon-socials.png new file mode 100644 index 0000000..8eeaeb2 Binary files /dev/null and b/public/icon-socials.png differ diff --git a/public/index.html b/public/index.html index b823281..8d04d2f 100644 --- a/public/index.html +++ b/public/index.html @@ -2,8 +2,11 @@ + + - + + diff --git a/public/loaf-logo.png b/public/loaf-logo.png new file mode 100644 index 0000000..ff67f09 Binary files /dev/null and b/public/loaf-logo.png differ diff --git a/public/shooting-star.png b/public/shooting-star.png new file mode 100644 index 0000000..617700b Binary files /dev/null and b/public/shooting-star.png differ diff --git a/public/tagline-image.png b/public/tagline-image.png new file mode 100644 index 0000000..93cccdf Binary files /dev/null and b/public/tagline-image.png differ diff --git a/src/App.js b/src/App.js index 074ba66..cb100e8 100644 --- a/src/App.js +++ b/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() { } /> } /> + } /> } /> } /> @@ -85,7 +98,49 @@ function App() { } /> - + + {/* Members Only Routes */} + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + @@ -142,6 +197,34 @@ function App() { } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> diff --git a/src/components/AddToCalendarButton.js b/src/components/AddToCalendarButton.js new file mode 100644 index 0000000..8726c4e --- /dev/null +++ b/src/components/AddToCalendarButton.js @@ -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 ( + + + + + + + {event && ( + <> + {/* Single Event Export Options */} +
+ Add This Event +
+ + handleCalendarAction('google')} + className="cursor-pointer" + > + + + + Google Calendar + + + handleCalendarAction('outlook')} + className="cursor-pointer" + > + + + + Outlook Web + + + handleCalendarAction('apple')} + className="cursor-pointer" + > + + + + Apple Calendar + + + handleCalendarAction('download')} + className="cursor-pointer" + > + + Download .ics File + + + {showSubscribe && } + + )} + + {showSubscribe && ( + <> + {/* Subscription Options */} +
+ Calendar Feeds +
+ + handleCalendarAction('subscribe')} + className="cursor-pointer" + > + + Subscribe to My Events +
+ Auto-syncs your RSVP'd events +
+
+ + handleCalendarAction('all-events')} + className="cursor-pointer" + > + + Download All Events +
+ One-time import of all upcoming events +
+
+ + )} + + {!event && !showSubscribe && ( +
+ No event selected +
+ )} +
+
+ ); +} diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js index 913efaf..efde1e4 100644 --- a/src/components/AdminSidebar.js +++ b/src/components/AdminSidebar.js @@ -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 }) => { )} + {/* Storage Usage Widget */} +
+ {isOpen ? ( +
+
+ Storage Usage + {storagePercentage}% +
+
+
90 ? 'bg-red-500' : + storagePercentage > 75 ? 'bg-yellow-500' : + 'bg-[#81B29A]' + }`} + style={{ width: `${storagePercentage}%` }} + /> +
+

+ {formatBytes(storageUsed)} / {formatBytes(storageLimit)} +

+
+ ) : ( +
+
+ 90 ? 'text-red-500' : + storagePercentage > 75 ? 'text-yellow-500' : + 'text-[#664fa3]' + }`} /> + {storagePercentage > 75 && ( +
+ )} + {/* Tooltip */} +
+ Storage: {storagePercentage}% +
+
+
+ )} +
+ {/* Logout Button */} - -
- {user ? ( - <> - {user.role === 'admin' && ( - - - - )} - - - - - - - - - ) : ( - <> - - - - - - - - )} -
-
-
- + )} + + + + + + + {/* Main Header - Member Navigation */} +
+ + LOAF Logo + + +
+ ); }; diff --git a/src/components/PublicFooter.js b/src/components/PublicFooter.js new file mode 100644 index 0000000..e191dea --- /dev/null +++ b/src/components/PublicFooter.js @@ -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 */} + + + {/* Bottom Footer */} + + + ); +}; + +export default PublicFooter; diff --git a/src/components/PublicNavbar.js b/src/components/PublicNavbar.js new file mode 100644 index 0000000..d6567fb --- /dev/null +++ b/src/components/PublicNavbar.js @@ -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 */} +
+ + {!user && ( + + Register + + )} + + + +
+ + {/* Main Header - Navigation */} +
+ + LOAF Logo + + +
+ + ); +}; + +export default PublicNavbar; diff --git a/src/pages/BecomeMember.js b/src/pages/BecomeMember.js new file mode 100644 index 0000000..f5ed8eb --- /dev/null +++ b/src/pages/BecomeMember.js @@ -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 ( +
+ + + {/* Decorative shooting star element */} +
+ +
+ + {/* Hero Section */} +
+
+

+ Become a Member +

+

+ 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 +

+
+
+ + {/* Annual Administrative Fees Section */} +
+
+
+ Admin Fee Icon +
+
+

+ Annual Administrative Fees +

+

+ Annual Administrative Fees for all members are $30 per person. These fees help cover general business expenses (website, advertising, e-newsletter). +

+
+
+
+ + {/* Membership Process Section */} +
+
+

+ Membership Process +

+

+ Becoming a member is easy, but for the safety and privacy of our membership, there are a few steps: +

+
+
+ + {/* Step 1 */} +
+
+
+ Step 1 Icon +
+
+

+ Step 1: Application & Email Confirmation +

+

+ 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. +

+
+
+
+ + {/* Arrow Down Icon */} +
+ +
+ + {/* Step 2 */} +
+
+
+ Step 2 Icon +
+
+

+ Step 2: Attend an event and meet us! +

+

+ 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). +

+
+
+
+ + {/* Arrow Down Icon */} +
+ +
+ + {/* Step 3 */} +
+
+
+ Step 3 Icon +
+
+

+ Step 3: Login and pay the annual fee +

+

+ 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. +

+
+
+
+ + {/* Arrow Down Icon */} +
+ +
+ + {/* Step 4 - With Gradient Background */} +
+
+
+ Step 4 Icon +
+
+

+ Step 4: Welcome to LOAF! +

+

+ Congratulations! Your application is complete, and you now have access to Members Only content. We hope to see you at future events soon! +

+
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to Join Us? +

+ + + +
+
+ + +
+ ); +}; + +export default BecomeMember; diff --git a/src/pages/Dashboard.js b/src/pages/Dashboard.js index ecf5542..3f203a0 100644 --- a/src/pages/Dashboard.js +++ b/src/pages/Dashboard.js @@ -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'}

+ {user?.subscription_start_date && user?.subscription_end_date && ( + <> +
+

Membership Period

+

+ {new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()} +

+
+
+

Days Remaining

+

+ {Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days +

+
+ + )} @@ -265,6 +293,7 @@ const Dashboard = () => { )} + ); }; diff --git a/src/pages/EventDetails.js b/src/pages/EventDetails.js index 3a7597b..a87ef89 100644 --- a/src/pages/EventDetails.js +++ b/src/pages/EventDetails.js @@ -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 + + {/* Add to Calendar Section */} +
+

+ Add to Your Calendar +

+

+ Never miss this event! Add it to your calendar app for reminders. +

+ +
+ ); }; diff --git a/src/pages/Events.js b/src/pages/Events.js index e08ab6a..ce63666 100644 --- a/src/pages/Events.js +++ b/src/pages/Events.js @@ -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 = () => { )} + ); }; diff --git a/src/pages/ForgotPassword.js b/src/pages/ForgotPassword.js index 10724c1..6f66c70 100644 --- a/src/pages/ForgotPassword.js +++ b/src/pages/ForgotPassword.js @@ -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 (
- +
@@ -114,6 +115,8 @@ const ForgotPassword = () => { )}
+ +
); }; diff --git a/src/pages/Landing.js b/src/pages/Landing.js index a2691c5..7e8f58d 100644 --- a/src/pages/Landing.js +++ b/src/pages/Landing.js @@ -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 (
- {/* Top Header - Auth Actions */} -
- - Register - - - Login - - -
- - {/* Main Header - Navigation */} -
- LOAF Logo - -
+ {/* Hero Section */}
@@ -144,63 +109,7 @@ const Landing = () => {
- {/* Main Footer */} - - - {/* Bottom Footer */} - +
); }; diff --git a/src/pages/Login.js b/src/pages/Login.js index 8c6bb1b..a227dc1 100644 --- a/src/pages/Login.js +++ b/src/pages/Login.js @@ -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 (
- +
@@ -129,6 +130,8 @@ const Login = () => {
+ +
); }; diff --git a/src/pages/Profile.js b/src/pages/Profile.js index 979b2b4..3f69e74 100644 --- a/src/pages/Profile.js +++ b/src/pages/Profile.js @@ -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} />
+ ); }; diff --git a/src/pages/Register.js b/src/pages/Register.js index 2237f7d..90e0d51 100644 --- a/src/pages/Register.js +++ b/src/pages/Register.js @@ -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 (
- +
@@ -284,6 +285,8 @@ const Register = () => {
+ +
); }; diff --git a/src/pages/ResetPassword.js b/src/pages/ResetPassword.js index 22c2e1d..be6dc5a 100644 --- a/src/pages/ResetPassword.js +++ b/src/pages/ResetPassword.js @@ -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 (
- +
@@ -140,6 +141,8 @@ const ResetPassword = () => {
+ +
); }; diff --git a/src/pages/VerifyEmail.js b/src/pages/VerifyEmail.js index 890b35b..05aa5e5 100644 --- a/src/pages/VerifyEmail.js +++ b/src/pages/VerifyEmail.js @@ -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 (
- +
@@ -98,6 +99,8 @@ const VerifyEmail = () => { )}
+ +
); }; diff --git a/src/pages/admin/AdminBylaws.js b/src/pages/admin/AdminBylaws.js new file mode 100644 index 0000000..38c0d56 --- /dev/null +++ b/src/pages/admin/AdminBylaws.js @@ -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 ( +
+

Loading bylaws...

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Bylaws Management +

+

+ Manage LOAF governing bylaws and version history +

+
+ +
+ + {/* Current Bylaws */} + {currentBylaws ? ( + +
+
+
+ +
+
+

+ {currentBylaws.title} +

+
+ + + Current Version + + + Version {currentBylaws.version} + +
+
+
+
+ + + +
+
+
+ Effective Date: {formatDate(currentBylaws.effective_date)} + + Document Type: {currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()} +
+
+ ) : ( + + +

No current bylaws set

+ +
+ )} + + {/* Historical Versions */} + {historicalBylaws.length > 0 && ( +
+

+ Version History ({historicalBylaws.length}) +

+
+ {historicalBylaws.map(bylawsDoc => ( + +
+
+

+ {bylawsDoc.title} +

+
+ Version {bylawsDoc.version} + + Effective {formatDate(bylawsDoc.effective_date)} +
+
+
+ + + +
+
+
+ ))} +
+
+ )} + + {/* Create/Edit Dialog */} + + + + + {selectedBylaws ? 'Edit Bylaws' : 'Add Bylaws Version'} + + + {selectedBylaws ? 'Update bylaws information' : 'Add a new version of the bylaws'} + + +
+
+
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="LOAF Bylaws" + required + /> +
+ +
+ + setFormData({ ...formData, version: e.target.value })} + placeholder="v1.0" + required + /> +
+ +
+ + setFormData({ ...formData, effective_date: e.target.value })} + required + /> +
+ +
+ + +
+ + {formData.document_type === 'upload' ? ( +
+ + setUploadedFile(e.target.files[0])} + required={!selectedBylaws} + /> + {uploadedFile && ( +

+ Selected: {uploadedFile.name} +

+ )} +
+ ) : ( +
+ + setFormData({ ...formData, document_url: e.target.value })} + placeholder="https://drive.google.com/file/d/..." + required + /> +

+ {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'} +

+
+ )} + +
+ setFormData({ ...formData, is_current: checked })} + /> + +
+
+ + + + +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Bylaws + + Are you sure you want to delete "{bylawsToDelete?.title} ({bylawsToDelete?.version})"? This action cannot be undone. + + + + + + + + +
+ ); +}; + +export default AdminBylaws; diff --git a/src/pages/admin/AdminFinancials.js b/src/pages/admin/AdminFinancials.js new file mode 100644 index 0000000..5787f7c --- /dev/null +++ b/src/pages/admin/AdminFinancials.js @@ -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 ( +
+

Loading financial reports...

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Financial Reports Management +

+

+ Manage annual financial reports +

+
+ +
+ + {/* Reports List */} + {reports.length === 0 ? ( + + +

No financial reports yet

+ +
+ ) : ( +
+ {reports.map(report => ( + +
+
+ +
{report.year}
+
+
+

+ {report.title} +

+
+ + {report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()} + + +
+
+
+ + +
+
+
+ ))} +
+ )} + + {/* Create/Edit Dialog */} + + + + + {selectedReport ? 'Edit Financial Report' : 'Add Financial Report'} + + + {selectedReport ? 'Update financial report information' : 'Add a new financial report'} + + +
+
+
+ + setFormData({ ...formData, year: parseInt(e.target.value) })} + placeholder="2024" + required + min="2000" + max="2100" + /> +
+ +
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="2024 Annual Financial Report" + required + /> +
+ +
+ + +
+ + {formData.document_type === 'upload' ? ( +
+ + setUploadedFile(e.target.files[0])} + required={!selectedReport} + /> + {uploadedFile && ( +

+ Selected: {uploadedFile.name} +

+ )} +
+ ) : ( +
+ + setFormData({ ...formData, document_url: e.target.value })} + placeholder="https://drive.google.com/file/d/..." + required + /> +

+ {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'} +

+
+ )} +
+ + + + +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Financial Report + + Are you sure you want to delete "{reportToDelete?.title}"? This action cannot be undone. + + + + + + + + +
+ ); +}; + +export default AdminFinancials; diff --git a/src/pages/admin/AdminGallery.js b/src/pages/admin/AdminGallery.js new file mode 100644 index 0000000..723518b --- /dev/null +++ b/src/pages/admin/AdminGallery.js @@ -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 ( +
+ {/* Header */} +
+

+ Event Gallery Management +

+

+ Upload and manage photos for event galleries +

+
+ + {/* Event Selection */} + +
+
+ + +
+ + {selectedEvent && ( +
+ + +

+ You can select multiple images. Max {formatFileSize(maxFileSize)} per image. +

+
+ )} +
+
+ + {/* Gallery Grid */} + {selectedEvent && ( + +
+

+ Gallery Images +

+ + {galleryImages.length} {galleryImages.length === 1 ? 'image' : 'images'} + +
+ + {galleryImages.length > 0 ? ( +
+ {galleryImages.map((image) => ( +
+
+ {image.caption +
+ + {/* Overlay with Actions */} +
+ + +
+ + {/* Caption Preview */} + {image.caption && ( +
+

+ {image.caption} +

+
+ )} + + {/* File Size */} +
+

+ {formatFileSize(image.file_size_bytes)} +

+
+
+ ))} +
+ ) : ( +
+ +

+ No Images Yet +

+

+ Upload images to create a gallery for this event. +

+
+ )} +
+ )} + + {/* Edit Caption Dialog */} + setEditingCaption(null)}> + + + + Edit Image Caption + + + + {editingCaption && ( +
+
+ Preview +
+ +
+ +