-
+
@@ -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 (
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Bylaws Management
+
+
+ Manage LOAF governing bylaws and version history
+
+
+
+
+ Add Version
+
+
+
+ {/* Current Bylaws */}
+ {currentBylaws ? (
+
+
+
+
+
+
+
+
+ {currentBylaws.title}
+
+
+
+
+ Current Version
+
+
+ Version {currentBylaws.version}
+
+
+
+
+
+ window.open(currentBylaws.document_url, '_blank')}
+ className="border-[#664fa3] text-[#664fa3]"
+ >
+
+ View
+
+ handleEdit(currentBylaws)}
+ className="border-[#664fa3] text-[#664fa3]"
+ >
+
+
+ handleDelete(currentBylaws)}
+ className="border-red-500 text-red-500 hover:bg-red-50"
+ >
+
+
+
+
+
+ Effective Date: {formatDate(currentBylaws.effective_date)}
+ •
+ Document Type: {currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()}
+
+
+ ) : (
+
+
+ No current bylaws set
+
+
+ Create Bylaws
+
+
+ )}
+
+ {/* Historical Versions */}
+ {historicalBylaws.length > 0 && (
+
+
+ Version History ({historicalBylaws.length})
+
+
+ {historicalBylaws.map(bylawsDoc => (
+
+
+
+
+ {bylawsDoc.title}
+
+
+ Version {bylawsDoc.version}
+ •
+ Effective {formatDate(bylawsDoc.effective_date)}
+
+
+
+ window.open(bylawsDoc.document_url, '_blank')}
+ className="border-[#664fa3] text-[#664fa3]"
+ >
+
+
+ handleEdit(bylawsDoc)}
+ className="border-[#664fa3] text-[#664fa3]"
+ >
+
+
+ handleDelete(bylawsDoc)}
+ className="border-red-500 text-red-500 hover:bg-red-50"
+ >
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Create/Edit Dialog */}
+
+
+
+
+ {selectedBylaws ? 'Edit Bylaws' : 'Add Bylaws Version'}
+
+
+ {selectedBylaws ? 'Update bylaws information' : 'Add a new version of the bylaws'}
+
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ Delete Bylaws
+
+ Are you sure you want to delete "{bylawsToDelete?.title} ({bylawsToDelete?.version})"? This action cannot be undone.
+
+
+
+ setDeleteDialogOpen(false)}
+ >
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ );
+};
+
+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
+
+
+
+
+ Add Report
+
+
+
+ {/* Reports List */}
+ {reports.length === 0 ? (
+
+
+ No financial reports yet
+
+
+ Create First Report
+
+
+ ) : (
+
+ {reports.map(report => (
+
+
+
+
+
+ {report.title}
+
+
+
+ {report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
+
+ window.open(report.document_url, '_blank')}
+ className="text-[#664fa3] hover:text-[#533a82]"
+ >
+
+ View
+
+
+
+
+ handleEdit(report)}
+ className="border-[#664fa3] text-[#664fa3]"
+ >
+
+
+ handleDelete(report)}
+ className="border-red-500 text-red-500 hover:bg-red-50"
+ >
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Create/Edit Dialog */}
+
+
+
+
+ {selectedReport ? 'Edit Financial Report' : 'Add Financial Report'}
+
+
+ {selectedReport ? 'Update financial report information' : 'Add a new financial report'}
+
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ Delete Financial Report
+
+ Are you sure you want to delete "{reportToDelete?.title}"? This action cannot be undone.
+
+
+
+ setDeleteDialogOpen(false)}
+ >
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ );
+};
+
+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 */}
+
+
+
+
+ Select Event
+
+
+
+
+
+
+ {events.map((event) => (
+
+ {event.title} - {moment(event.start_at).format('MMM D, YYYY')}
+
+ ))}
+
+
+
+
+ {selectedEvent && (
+
+
+
fileInputRef.current?.click()}
+ disabled={uploading}
+ className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
+ style={{ fontFamily: "'Inter', sans-serif" }}
+ >
+ {uploading ? (
+ <>
+
+ Uploading...
+ >
+ ) : (
+ <>
+
+ Upload Images
+ >
+ )}
+
+
+ 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) => (
+
+
+
+
+
+ {/* Overlay with Actions */}
+
+ openEditCaption(image)}
+ size="sm"
+ className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
+ style={{ fontFamily: "'Inter', sans-serif" }}
+ >
+
+ Caption
+
+ handleDeleteImage(image.id)}
+ size="sm"
+ className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
+ style={{ fontFamily: "'Inter', sans-serif" }}
+ >
+
+ Delete
+
+
+
+ {/* Caption Preview */}
+ {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 && (
+
+
+
+
+
+
+
+ Caption
+
+
+
+ )}
+
+
+ setEditingCaption(null)}
+ variant="outline"
+ className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
+ style={{ fontFamily: "'Inter', sans-serif" }}
+ >
+ Cancel
+
+
+ Save Caption
+
+
+
+
+
+ );
+};
+
+export default AdminGallery;
diff --git a/src/pages/admin/AdminNewsletters.js b/src/pages/admin/AdminNewsletters.js
new file mode 100644
index 0000000..1e63c22
--- /dev/null
+++ b/src/pages/admin/AdminNewsletters.js
@@ -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 (
+
+
Loading newsletters...
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Newsletter Management
+
+
+ Create and manage newsletter archive
+
+
+
+
+ Add Newsletter
+
+
+
+ {/* Newsletters List */}
+ {newsletters.length === 0 ? (
+
+
+ No newsletters yet
+
+
+ Create First Newsletter
+
+
+ ) : (
+
+ {sortedYears.map(year => (
+
+
+
+ {year}
+
+
+ {groupedNewsletters[year].map(newsletter => (
+
+
+
+
+ {newsletter.title}
+
+ {newsletter.description && (
+
{newsletter.description}
+ )}
+
+
+ {formatDate(newsletter.published_date)}
+
+
+ {newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
+
+ window.open(newsletter.document_url, '_blank')}
+ className="text-[#664fa3] hover:text-[#533a82]"
+ >
+
+ View
+
+
+
+
+ handleEdit(newsletter)}
+ className="border-[#664fa3] text-[#664fa3]"
+ >
+
+
+ handleDelete(newsletter)}
+ className="border-red-500 text-red-500 hover:bg-red-50"
+ >
+
+
+
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ {/* Create/Edit Dialog */}
+
+
+
+
+ {selectedNewsletter ? 'Edit Newsletter' : 'Add Newsletter'}
+
+
+ {selectedNewsletter ? 'Update newsletter information' : 'Add a new newsletter to the archive'}
+
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ Delete Newsletter
+
+ Are you sure you want to delete "{newsletterToDelete?.title}"? This action cannot be undone.
+
+
+
+ setDeleteDialogOpen(false)}
+ >
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ );
+};
+
+export default AdminNewsletters;
diff --git a/src/pages/members/Bylaws.js b/src/pages/members/Bylaws.js
new file mode 100644
index 0000000..c62025f
--- /dev/null
+++ b/src/pages/members/Bylaws.js
@@ -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 (
+
+
+
+
+ Loading bylaws...
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ LOAF Bylaws
+
+
+ Review the official governing bylaws and policies of the LOAF community.
+
+
+
+ {/* Current Bylaws */}
+ {currentBylaws ? (
+
+
+
+
+
+
+
+
+ {currentBylaws.title}
+
+
+
+ Current Version
+
+
+
+
+ Version: {currentBylaws.version}
+
+ •
+
+ Effective: {formatDate(currentBylaws.effective_date)}
+
+
+
window.open(currentBylaws.document_url, '_blank')}
+ size="lg"
+ className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
+ >
+
+ View Current Bylaws
+
+
+
+
+ ) : (
+
+
+
+ No current bylaws document available
+
+
+ )}
+
+ {/* Version History Toggle */}
+ {history.length > 1 && (
+
+ setShowHistory(!showHistory)}
+ variant="outline"
+ className="w-full border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9] rounded-full flex items-center justify-center gap-2"
+ >
+
+ {showHistory ? 'Hide' : 'View'} Version History ({history.length - 1} previous {history.length - 1 === 1 ? 'version' : 'versions'})
+
+
+ )}
+
+ {/* Version History */}
+ {showHistory && history.length > 1 && (
+
+
+ Previous Versions
+
+ {history.filter(b => !b.is_current).map(bylaws => (
+
+
+
+
+ {bylaws.title}
+
+
+ Version {bylaws.version}
+ •
+ Effective {formatDate(bylaws.effective_date)}
+
+
+
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"
+ >
+
+ View
+
+
+
+ ))}
+
+ )}
+
+ {/* Information Card */}
+
+
+
+
+
+ About LOAF Bylaws
+
+
+ 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.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/members/EventGallery.js b/src/pages/members/EventGallery.js
new file mode 100644
index 0000000..0ffdf0e
--- /dev/null
+++ b/src/pages/members/EventGallery.js
@@ -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 }) => (
+
handleEventClick(event)}
+ >
+ {/* Thumbnail */}
+
+ {event.thumbnail_url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {event.gallery_count} {event.gallery_count === 1 ? 'photo' : 'photos'}
+
+
+
+
+ {/* Event Info */}
+
+ {event.title}
+
+
+ {event.description && (
+
+ {event.description}
+
+ )}
+
+
+
+
+
+ {moment(event.start_at).format('MMMM D, YYYY')}
+
+
+
+
+ {event.location}
+
+
+
+ );
+
+ // Event Gallery Grid View
+ if (!selectedEvent) {
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ Event Gallery
+
+
+ Browse photos from past LOAF events.
+
+
+
+ {/* Events Grid */}
+ {loading ? (
+
+ ) : events.length > 0 ? (
+
+ {events.map((event) => (
+
+ ))}
+
+ ) : (
+
+
+
+ No Event Galleries Yet
+
+
+ Event photos will appear here once admins upload them.
+
+
+ )}
+
+
+ );
+ }
+
+ // Individual Event Gallery View
+ return (
+
+
+
+
+ {/* Back Button */}
+
+
+ Back to All Galleries
+
+
+ {/* Event Header */}
+
+
+ {selectedEvent.title}
+
+
+
+
+
+ {moment(selectedEvent.start_at).format('MMMM D, YYYY')}
+
+
+
+
+ {selectedEvent.location}
+
+
+ {selectedEvent.gallery_count} {selectedEvent.gallery_count === 1 ? 'photo' : 'photos'}
+
+
+
+
+ {/* Gallery Grid */}
+ {galleryLoading ? (
+
+ ) : galleryImages.length > 0 ? (
+
+ {galleryImages.map((image, index) => (
+
openLightbox(index)}
+ >
+
+
+
+
+ {image.caption && (
+
+ )}
+
+ ))}
+
+ ) : (
+
+
+
+ No Photos Yet
+
+
+ Photos from this event will appear here once uploaded.
+
+
+ )}
+
+
+ {/* Lightbox Modal */}
+
+
+ {selectedImageIndex !== null && galleryImages[selectedImageIndex] && (
+
+ {/* Close Button */}
+
+
+
+
+ {/* Previous Button */}
+ {galleryImages.length > 1 && (
+
+
+
+ )}
+
+ {/* Image */}
+
+
+ {galleryImages[selectedImageIndex].caption && (
+
+
+ {galleryImages[selectedImageIndex].caption}
+
+
+ )}
+
+ {selectedImageIndex + 1} / {galleryImages.length}
+
+
+
+ {/* Next Button */}
+ {galleryImages.length > 1 && (
+
+
+
+ )}
+
+ )}
+
+
+
+
+ );
+};
+
+export default EventGallery;
diff --git a/src/pages/members/Financials.js b/src/pages/members/Financials.js
new file mode 100644
index 0000000..84612b7
--- /dev/null
+++ b/src/pages/members/Financials.js
@@ -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 (
+
+
+
+
+ Loading financial reports...
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ Financial Reports
+
+
+ Access annual financial reports and stay informed about LOAF's fiscal responsibility.
+
+
+
+ {/* Reports List */}
+ {reports.length === 0 ? (
+
+
+
+ No financial reports available yet
+
+
+ ) : (
+
+ {reports.map(report => (
+
+
+ {/* Year Badge */}
+
+
+
+ {report.year}
+
+
Fiscal Year
+
+
+ {/* Report Details */}
+
+
+ {report.title}
+
+
+
+ {report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
+
+
+
window.open(report.document_url, '_blank')}
+ className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
+ >
+
+ View Report
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Transparency Note */}
+ {reports.length > 0 && (
+
+
+
+
+
+ Transparency & Accountability
+
+
+ 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.
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/members/MemberCalendar.js b/src/pages/members/MemberCalendar.js
new file mode 100644
index 0000000..4a97f89
--- /dev/null
+++ b/src/pages/members/MemberCalendar.js
@@ -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 (
+
+
+
+
+ Loading calendar...
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Event Calendar
+
+
+ View and manage your event RSVPs. Click on any event to see details and update your RSVP.
+
+
+
+
+
+
+
+
+
+
+
+ {selectedEvent && (
+ <>
+
+
+
+
+
+ {selectedEvent.user_rsvp_status && (
+
+ {selectedEvent.user_rsvp_status === 'yes' && 'Going'}
+ {selectedEvent.user_rsvp_status === 'no' && 'Not Going'}
+ {selectedEvent.user_rsvp_status === 'maybe' && 'Maybe'}
+
+ )}
+
+
+ {selectedEvent.title}
+
+
+
+
+
+
+
+
+ {new Date(selectedEvent.start_at).toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })}
+
+
+
+
+
+ {new Date(selectedEvent.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - {new Date(selectedEvent.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+
+
+ {selectedEvent.location}
+
+
+
+
+ {selectedEvent.rsvp_count || 0} {selectedEvent.rsvp_count === 1 ? 'person' : 'people'} attending
+ {selectedEvent.capacity && ` (Capacity: ${selectedEvent.capacity})`}
+
+
+
+
+ {selectedEvent.description && (
+
+
+ About This Event
+
+
+ {selectedEvent.description}
+
+
+ )}
+
+
+
+ Your RSVP
+
+
+ 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]'
+ }`}
+ >
+
+ I'm Going
+
+ 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]'
+ }`}
+ >
+
+ Maybe
+
+ 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'
+ }`}
+ >
+
+ Can't Attend
+
+
+
+
+
+
+ Add to Your Calendar
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/members/MemberProfile.js b/src/pages/members/MemberProfile.js
new file mode 100644
index 0000000..f2f9578
--- /dev/null
+++ b/src/pages/members/MemberProfile.js
@@ -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 (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ Member Profile
+
+
+ Enhance your profile with a photo and social media links.
+
+
+
+
+ {/* Profile Photo Section */}
+
+
+ Profile Photo
+
+
+
+
+ {previewImage ? (
+
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
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 ? (
+ <>
+
+ Uploading...
+ >
+ ) : (
+ <>
+
+ {previewImage ? 'Change Photo' : 'Upload Photo'}
+ >
+ )}
+
+
+ JPG, PNG, WebP, or GIF. Max 50MB.
+
+
+
+
+
+ {/* Social Media Section */}
+
+
+ Social Media Links
+
+
+
+
+
+ {/* Directory Settings Section */}
+
+
+ Directory Settings
+
+
+
+
+
+
+
+ Show in Members Directory
+
+
+ Allow other members to see your profile in the directory
+
+
+
+
+
+ {formData.show_in_directory && (
+ <>
+
+
+ Directory Email (visible to members)
+
+
+
+
+
+
+ Bio (visible to members)
+
+
+
+
+
+
+
+
+ Partner Name (if applicable)
+
+
+
+ >
+ )}
+
+
+
+ {/* Submit Button */}
+
+
+ {saving ? (
+ 'Saving...'
+ ) : (
+ <>
+
+ Save Profile
+ >
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default MemberProfile;
diff --git a/src/pages/members/MembersDirectory.js b/src/pages/members/MembersDirectory.js
new file mode 100644
index 0000000..c61ea72
--- /dev/null
+++ b/src/pages/members/MembersDirectory.js
@@ -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 }) => (
+
+ {/* Profile Photo */}
+
+ {member.profile_photo_url ? (
+
+ ) : (
+
+
+ {getInitials(member.first_name, member.last_name)}
+
+
+ )}
+
+
+ {/* Name */}
+
+ {member.first_name} {member.last_name}
+
+
+ {/* Partner Name */}
+ {member.directory_partner_name && (
+
+
+
+ Partner: {member.directory_partner_name}
+
+
+ )}
+
+ {/* Bio */}
+ {member.directory_bio && (
+
+ {member.directory_bio}
+
+ )}
+
+ {/* Contact Information */}
+
+ {member.directory_email && (
+
+ )}
+
+ {member.directory_phone && (
+
+ )}
+
+ {member.directory_address && (
+
+
+
+ {member.directory_address}
+
+
+ )}
+
+
+ {/* Social Media Links */}
+ {(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
+
+
+ {member.social_media_facebook && (
+
+
+
+ )}
+
+ {member.social_media_instagram && (
+
+
+
+ )}
+
+ {member.social_media_twitter && (
+
+
+
+ )}
+
+ {member.social_media_linkedin && (
+
+
+
+ )}
+
+
+ )}
+
+ );
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ Members Directory
+
+
+ Connect with fellow LOAF members in our community.
+
+
+
+ {/* Search Bar */}
+
+
+
+ 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" }}
+ />
+
+ {searchQuery && (
+
+ Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
+
+ )}
+
+
+ {/* Members Grid */}
+ {loading ? (
+
+ ) : filteredMembers.length > 0 ? (
+
+ {filteredMembers.map((member) => (
+
+ ))}
+
+ ) : (
+
+
+
+ {searchQuery ? 'No Members Found' : 'No Members in Directory'}
+
+
+ {searchQuery
+ ? 'Try adjusting your search query.'
+ : 'Members who opt in to the directory will appear here.'}
+
+
+ )}
+
+ {/* Info Card */}
+ {!loading && members.length > 0 && (
+
+
+
+
+
+
+
+ Want to appear in the directory?
+
+
+ Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '}
+
+ Edit your profile →
+
+
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default MembersDirectory;
diff --git a/src/pages/members/NewsletterArchive.js b/src/pages/members/NewsletterArchive.js
new file mode 100644
index 0000000..e8dd175
--- /dev/null
+++ b/src/pages/members/NewsletterArchive.js
@@ -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 (
+
+
+
+
+ Loading newsletters...
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ Newsletter Archive
+
+
+ Browse past monthly newsletters and stay informed about LOAF community updates.
+
+
+ {/* Filters */}
+
+ {/* Search */}
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-10 border-[#ddd8eb] focus:border-[#664fa3]"
+ />
+
+
+ {/* Year Filter */}
+
+
+ All Years
+
+ {years.map(year => (
+ handleYearFilter(year)}
+ variant={selectedYear === year ? "default" : "outline"}
+ size="sm"
+ className={selectedYear === year ? "bg-[#664fa3] text-white" : "border-[#664fa3] text-[#664fa3]"}
+ >
+ {year}
+
+ ))}
+
+
+
+
+ {/* Newsletter List */}
+ {filteredNewsletters.length === 0 ? (
+
+
+
+ No newsletters found
+
+
+ ) : (
+
+ {sortedYears.map(year => (
+
+
+
+ {year}
+
+
+ {groupedNewsletters[year].map(newsletter => (
+
+
+
+
+
+
+
+ {newsletter.title}
+
+ {newsletter.description && (
+
+ {newsletter.description}
+
+ )}
+
+
+ {formatDate(newsletter.published_date)}
+
+
+ {newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
+
+
+
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"
+ >
+
+ View Newsletter
+
+
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/yarn.lock b/yarn.lock
index e2b9431..9dee53b 100644
--- a/yarn.lock
+++ b/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"