diff --git a/package.json b/package.json index 32e9276..5663868 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "frontend", "version": "0.1.0", "private": true, - "homepage": "/become-a-member", + "homepage": "/", "dependencies": { "@fontsource/dm-sans": "^5.2.8", "@fontsource/fraunces": "^5.2.9", diff --git a/src/App.js b/src/App.js index 30ffde2..d706f13 100644 --- a/src/App.js +++ b/src/App.js @@ -23,6 +23,7 @@ import AdminMembers from './pages/admin/AdminMembers'; import AdminPermissions from './pages/admin/AdminPermissions'; import AdminRoles from './pages/admin/AdminRoles'; import AdminEvents from './pages/admin/AdminEvents'; +import AdminEventAttendance from './pages/admin/AdminEventAttendance'; import AdminValidations from './pages/admin/AdminValidations'; import AdminPlans from './pages/admin/AdminPlans'; import AdminSubscriptions from './pages/admin/AdminSubscriptions'; @@ -50,6 +51,8 @@ import Resources from './pages/Resources'; import ContactUs from './pages/ContactUs'; import TermsOfService from './pages/TermsOfService'; import PrivacyPolicy from './pages/PrivacyPolicy'; +import AcceptInvitation from './pages/AcceptInvitation'; +import NotFound from './pages/NotFound'; const PrivateRoute = ({ children, adminOnly = false }) => { const { user, loading } = useAuth(); @@ -82,6 +85,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> + + + + + + } /> @@ -278,6 +289,9 @@ function App() { } /> + + {/* 404 - Catch all undefined routes */} + } /> diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js index f22f44d..07757aa 100644 --- a/src/components/AdminSidebar.js +++ b/src/components/AdminSidebar.js @@ -265,7 +265,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { > {/* Header */}
-
+ LOAF Logo { }`} /> {isOpen && ( -

- Admin -

+
+

+ Admin +

+

+ View Public Site +

+
)} -
+ + +
+ ); + } + return (
diff --git a/src/pages/NotFound.js b/src/pages/NotFound.js new file mode 100644 index 0000000..21d68b0 --- /dev/null +++ b/src/pages/NotFound.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '../components/ui/button'; +import { Card } from '../components/ui/card'; +import { Home, ArrowLeft, Search } from 'lucide-react'; + +const NotFound = () => { + const navigate = useNavigate(); + + return ( +
+ + {/* 404 Illustration */} +
+
+

+ 404 +

+
+ +
+
+
+ + {/* Message */} +

+ Page Not Found +

+

+ Oops! The page you're looking for doesn't exist. It might have been moved or deleted. +

+ + {/* Action Buttons */} +
+ + +
+ + {/* Help Text */} +
+

+ Need help? Contact us at{' '} + + support@loaftx.org + +

+
+
+
+ ); +}; + +export default NotFound; diff --git a/src/pages/Plans.js b/src/pages/Plans.js index a6fc137..f4025c8 100644 --- a/src/pages/Plans.js +++ b/src/pages/Plans.js @@ -253,7 +253,13 @@ const Plans = () => {

Loading plans...

) : plans.length > 0 ? ( -
+
{plans.map((plan) => { const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000; const suggestedPrice = plan.suggested_price_cents || minimumPrice; diff --git a/src/pages/admin/AdminBylaws.js b/src/pages/admin/AdminBylaws.js index 38c0d56..e66ece4 100644 --- a/src/pages/admin/AdminBylaws.js +++ b/src/pages/admin/AdminBylaws.js @@ -44,7 +44,7 @@ const AdminBylaws = () => { version: '', effective_date: '', document_url: '', - document_type: 'google_drive', + document_type: 'link', is_current: false }); const [submitting, setSubmitting] = useState(false); @@ -71,9 +71,10 @@ const AdminBylaws = () => { version: '', effective_date: new Date().toISOString().split('T')[0], document_url: '', - document_type: 'google_drive', + document_type: 'link', is_current: bylaws.length === 0 // Auto-check if this is the first bylaws }); + setUploadedFile(null); setDialogOpen(true); }; @@ -246,7 +247,7 @@ const AdminBylaws = () => {
Effective Date: {formatDate(currentBylaws.effective_date)} • - Document Type: {currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()} + Document Type: {currentBylaws.document_type === 'upload' ? 'PDF Upload' : 'Link'}
) : ( @@ -363,14 +364,16 @@ const AdminBylaws = () => { @@ -391,6 +394,11 @@ const AdminBylaws = () => { Selected: {uploadedFile.name}

)} + {selectedBylaws && !uploadedFile && ( +

+ Current file will be kept if no new file is selected +

+ )}
) : (
@@ -399,12 +407,11 @@ const AdminBylaws = () => { id="document_url" value={formData.document_url} onChange={(e) => setFormData({ ...formData, document_url: e.target.value })} - placeholder="https://drive.google.com/file/d/..." + placeholder="https://docs.google.com/... or https://example.com/file.pdf" 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'} + Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)

)} diff --git a/src/pages/admin/AdminEventAttendance.js b/src/pages/admin/AdminEventAttendance.js new file mode 100644 index 0000000..5fa5c09 --- /dev/null +++ b/src/pages/admin/AdminEventAttendance.js @@ -0,0 +1,548 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import api from '../../utils/api'; +import { Card } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Input } from '../../components/ui/input'; +import { Badge } from '../../components/ui/badge'; +import { Checkbox } from '../../components/ui/checkbox'; +import { + ArrowLeft, + Calendar, + MapPin, + Download, + Check, + X, + Search, + Users, + UserCheck, + UserX, + HelpCircle +} from 'lucide-react'; +import { toast } from 'sonner'; +import moment from 'moment'; + +const AdminEventAttendance = () => { + const { eventId } = useParams(); + const navigate = useNavigate(); + + const [event, setEvent] = useState(null); + const [rsvps, setRsvps] = useState([]); + const [filteredRsvps, setFilteredRsvps] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + // Filters and search + const [activeTab, setActiveTab] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + // Bulk selection + const [selectedRsvps, setSelectedRsvps] = useState(new Set()); + const [selectAll, setSelectAll] = useState(false); + + useEffect(() => { + fetchEventAndRsvps(); + }, [eventId]); + + useEffect(() => { + filterRsvps(); + }, [rsvps, activeTab, searchQuery]); + + const fetchEventAndRsvps = async () => { + try { + setLoading(true); + const [eventRes, rsvpsRes] = await Promise.all([ + api.get(`/admin/events/${eventId}`), + api.get(`/admin/events/${eventId}/rsvps`) + ]); + setEvent(eventRes.data); + setRsvps(rsvpsRes.data); + } catch (error) { + console.error('Failed to fetch event data:', error); + toast.error('Failed to load event data'); + } finally { + setLoading(false); + } + }; + + const filterRsvps = () => { + let filtered = [...rsvps]; + + // Filter by RSVP status tab + if (activeTab !== 'all') { + filtered = filtered.filter(rsvp => rsvp.rsvp_status === activeTab); + } + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(rsvp => + rsvp.user_name?.toLowerCase().includes(query) || + rsvp.user_email?.toLowerCase().includes(query) + ); + } + + setFilteredRsvps(filtered); + }; + + const handleSelectAll = () => { + if (selectAll) { + setSelectedRsvps(new Set()); + } else { + setSelectedRsvps(new Set(filteredRsvps.map(rsvp => rsvp.user_id))); + } + setSelectAll(!selectAll); + }; + + const handleSelectRsvp = (userId) => { + const newSelected = new Set(selectedRsvps); + if (newSelected.has(userId)) { + newSelected.delete(userId); + } else { + newSelected.add(userId); + } + setSelectedRsvps(newSelected); + setSelectAll(newSelected.size === filteredRsvps.length); + }; + + const handleBulkAttendance = async (attended) => { + if (selectedRsvps.size === 0) { + toast.error('Please select at least one RSVP'); + return; + } + + try { + setSaving(true); + const updates = Array.from(selectedRsvps).map(userId => ({ + user_id: userId, + attended + })); + + await api.put(`/admin/events/${eventId}/attendance`, { updates }); + + toast.success(`Marked ${selectedRsvps.size} ${selectedRsvps.size === 1 ? 'person' : 'people'} as ${attended ? 'attended' : 'not attended'}`); + + // Refresh data + await fetchEventAndRsvps(); + + // Clear selection + setSelectedRsvps(new Set()); + setSelectAll(false); + } catch (error) { + console.error('Failed to update attendance:', error); + toast.error('Failed to update attendance'); + } finally { + setSaving(false); + } + }; + + const handleIndividualAttendance = async (userId, attended) => { + try { + setSaving(true); + const updates = [{ + user_id: userId, + attended + }]; + + await api.put(`/admin/events/${eventId}/attendance`, { updates }); + + toast.success(`Attendance ${attended ? 'confirmed' : 'removed'}`); + + // Refresh data + await fetchEventAndRsvps(); + } catch (error) { + console.error('Failed to update attendance:', error); + toast.error('Failed to update attendance'); + } finally { + setSaving(false); + } + }; + + const exportToCSV = () => { + if (filteredRsvps.length === 0) { + toast.error('No RSVPs to export'); + return; + } + + // CSV header + const headers = ['Name', 'Email', 'RSVP Status', 'Attended', 'Attended At']; + + // CSV rows + const rows = filteredRsvps.map(rsvp => [ + `"${rsvp.user_name}"`, + `"${rsvp.user_email}"`, + `"${rsvp.rsvp_status.toUpperCase()}"`, + rsvp.attended ? 'Yes' : 'No', + rsvp.attended_at ? `"${moment(rsvp.attended_at).format('YYYY-MM-DD HH:mm A')}"` : '' + ]); + + // Combine headers and rows + const csvContent = [ + headers.join(','), + ...rows.map(row => row.join(',')) + ].join('\n'); + + // Create blob and download + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', `${event?.title.replace(/\s+/g, '_')}_RSVPs_${moment().format('YYYY-MM-DD')}.csv`); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success('CSV exported successfully'); + }; + + const getStats = () => { + const total = rsvps.length; + const yesCount = rsvps.filter(r => r.rsvp_status === 'yes').length; + const noCount = rsvps.filter(r => r.rsvp_status === 'no').length; + const maybeCount = rsvps.filter(r => r.rsvp_status === 'maybe').length; + const attendedCount = rsvps.filter(r => r.attended).length; + + return { total, yesCount, noCount, maybeCount, attendedCount }; + }; + + const stats = getStats(); + + if (loading) { + return ( +
+
Loading event data...
+
+ ); + } + + if (!event) { + return ( +
+

Event not found

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

+ Event Attendance +

+

+ Manage RSVPs and track attendance for this event +

+
+
+ +
+ + {/* Event Details Card */} + +
+
+

+ {event.title} +

+
+
+ + {moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')} +
+ {event.location && ( +
+ + {event.location} +
+ )} +
+
+ + {event.published ? 'Published' : 'Draft'} + +
+
+ + {/* Statistics Cards */} +
+ +
+ +
+

Total RSVPs

+

{stats.total}

+
+
+
+ + +
+ +
+

Yes

+

{stats.yesCount}

+
+
+
+ + +
+ +
+

No

+

{stats.noCount}

+
+
+
+ + +
+ +
+

Maybe

+

{stats.maybeCount}

+
+
+
+ + +
+ +
+

Attended

+

{stats.attendedCount}

+
+
+
+
+ + {/* Filters and Actions */} + +
+ {/* Tab Filters */} +
+ + + + +
+ + {/* Search and Bulk Actions */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 border-[#ddd8eb] rounded-xl" + style={{ fontFamily: "'Nunito Sans', sans-serif" }} + /> +
+ + {selectedRsvps.size > 0 && ( +
+ + {selectedRsvps.size} selected + + + +
+ )} +
+
+
+ + {/* RSVP Table */} + +
+ + + + + + + + + + + + + {filteredRsvps.length > 0 ? ( + filteredRsvps.map((rsvp) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
+ + + Name + + Email + + RSVP Status + + Attendance + + Attended At +
+ handleSelectRsvp(rsvp.user_id)} + /> + + {rsvp.user_name} + + {rsvp.user_email} + + + {rsvp.rsvp_status.toUpperCase()} + + + {rsvp.attended ? ( + + ) : ( + + )} + + {rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'} +
+

+ {searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'} +

+
+
+
+
+ ); +}; + +export default AdminEventAttendance; diff --git a/src/pages/admin/AdminEvents.js b/src/pages/admin/AdminEvents.js index 9c8b89c..8d25af7 100644 --- a/src/pages/admin/AdminEvents.js +++ b/src/pages/admin/AdminEvents.js @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import api from '../../utils/api'; import { Card } from '../../components/ui/card'; @@ -8,16 +9,14 @@ import { Input } from '../../components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog'; import { toast } from 'sonner'; import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react'; -import { AttendanceDialog } from '../../components/AttendanceDialog'; const AdminEvents = () => { + const navigate = useNavigate(); const { hasPermission } = useAuth(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [editingEvent, setEditingEvent] = useState(null); - const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false); - const [selectedEvent, setSelectedEvent] = useState(null); const [formData, setFormData] = useState({ title: '', @@ -342,19 +341,16 @@ const AdminEvents = () => { {/* Actions */}
- {/* Mark Attendance Button */} + {/* Manage Attendance Button */} {/* Other Actions */} @@ -419,14 +415,6 @@ const AdminEvents = () => {
)} - - {/* Attendance Dialog */} - ); }; diff --git a/src/pages/admin/AdminFinancials.js b/src/pages/admin/AdminFinancials.js index 5787f7c..e79a2b7 100644 --- a/src/pages/admin/AdminFinancials.js +++ b/src/pages/admin/AdminFinancials.js @@ -42,7 +42,7 @@ const AdminFinancials = () => { year: new Date().getFullYear(), title: '', document_url: '', - document_type: 'google_drive' + document_type: 'link' }); const [submitting, setSubmitting] = useState(false); @@ -67,8 +67,9 @@ const AdminFinancials = () => { year: new Date().getFullYear(), title: '', document_url: '', - document_type: 'google_drive' + document_type: 'link' }); + setUploadedFile(null); setDialogOpen(true); }; @@ -274,14 +275,16 @@ const AdminFinancials = () => { @@ -302,6 +305,11 @@ const AdminFinancials = () => { Selected: {uploadedFile.name}

)} + {selectedReport && !uploadedFile && ( +

+ Current file will be kept if no new file is selected +

+ )}
) : (
@@ -310,12 +318,11 @@ const AdminFinancials = () => { id="document_url" value={formData.document_url} onChange={(e) => setFormData({ ...formData, document_url: e.target.value })} - placeholder="https://drive.google.com/file/d/..." + placeholder="https://docs.google.com/... or https://example.com/file.pdf" 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'} + Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)

)} diff --git a/src/pages/admin/AdminGallery.js b/src/pages/admin/AdminGallery.js index 723518b..e7aac7d 100644 --- a/src/pages/admin/AdminGallery.js +++ b/src/pages/admin/AdminGallery.js @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; +import { Link } from 'react-router-dom'; import api from '../../utils/api'; import { Card } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; @@ -14,7 +15,7 @@ import { 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 { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin, AlertCircle } from 'lucide-react'; import { toast } from 'sonner'; import moment from 'moment'; @@ -179,6 +180,32 @@ const AdminGallery = () => { + {/* Empty State Message */} + {events.length === 0 && ( +
+
+ +
+

+ No Events Available +

+

+ You need to create an event before uploading gallery images. Events help organize photos by occasion. +

+ + + +
+
+
+ )} + {selectedEvent && (
{ description: '', published_date: '', document_url: '', - document_type: 'google_docs' + document_type: 'link' }); const [submitting, setSubmitting] = useState(false); @@ -70,8 +70,9 @@ const AdminNewsletters = () => { description: '', published_date: new Date().toISOString().split('T')[0], document_url: '', - document_type: 'google_docs' + document_type: 'link' }); + setUploadedFile(null); setDialogOpen(true); }; @@ -232,7 +233,7 @@ const AdminNewsletters = () => { {formatDate(newsletter.published_date)} - {newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()} + {newsletter.document_type === 'upload' ? 'PDF Upload' : 'Link'}
) : (
@@ -358,12 +366,11 @@ const AdminNewsletters = () => { id="document_url" value={formData.document_url} onChange={(e) => setFormData({ ...formData, document_url: e.target.value })} - placeholder="https://docs.google.com/document/d/..." + placeholder="https://docs.google.com/document/d/... or https://example.com/file.pdf" required />

- {formData.document_type === 'google_docs' && 'Paste the shareable link to your Google Doc'} - {formData.document_type === 'pdf' && 'Paste the URL to your PDF file'} + Paste the shareable link to your document (Google Docs, Dropbox, PDF URL, etc.)

)} diff --git a/src/pages/admin/AdminStaff.js b/src/pages/admin/AdminStaff.js index a17ac73..289f908 100644 --- a/src/pages/admin/AdminStaff.js +++ b/src/pages/admin/AdminStaff.js @@ -12,7 +12,7 @@ import CreateStaffDialog from '../../components/CreateStaffDialog'; import InviteStaffDialog from '../../components/InviteStaffDialog'; import PendingInvitationsTable from '../../components/PendingInvitationsTable'; import { toast } from 'sonner'; -import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye } from 'lucide-react'; +import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX } from 'lucide-react'; const AdminStaff = () => { const navigate = useNavigate(); @@ -71,6 +71,32 @@ const AdminStaff = () => { setFilteredUsers(filtered); }; + const handleToggleStatus = async (userId, currentStatus) => { + const newStatus = currentStatus === 'active' ? 'inactive' : 'active'; + + try { + await api.put(`/admin/users/${userId}/status`, { status: newStatus }); + toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`); + fetchStaff(); // Refresh list + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to update user status'); + } + }; + + const handleDeleteUser = async (userId, userName) => { + if (!window.confirm(`Are you sure you want to delete ${userName}? This action cannot be undone.`)) { + return; + } + + try { + await api.delete(`/admin/users/${userId}`); + toast.success('User deleted successfully'); + fetchStaff(); // Refresh list + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to delete user'); + } + }; + const getRoleBadge = (role) => { const config = { superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' }, @@ -250,7 +276,7 @@ const AdminStaff = () => { {/* Actions */} -
+
+ + {hasPermission('users.status') && ( + + )} + + {hasPermission('users.delete') && ( + + )}
diff --git a/src/pages/members/MemberCalendar.css b/src/pages/members/MemberCalendar.css new file mode 100644 index 0000000..8f66ebf --- /dev/null +++ b/src/pages/members/MemberCalendar.css @@ -0,0 +1,93 @@ +/* Member Calendar Custom Styles */ + +.member-calendar .rbc-header { + padding: 12px 6px; + font-family: 'Inter', sans-serif; + font-weight: 600; + color: #422268; + background-color: #f9f7fc; + border-bottom: 2px solid #ddd8eb; +} + +.member-calendar .rbc-today { + background-color: #f1eef9; +} + +.member-calendar .rbc-off-range-bg { + background-color: #fafafa; +} + +.member-calendar .rbc-event { + border-radius: 6px; + padding: 2px 6px; +} + +.member-calendar .rbc-event:hover { + opacity: 0.85; + cursor: pointer; +} + +.member-calendar .rbc-toolbar button { + color: #664fa3; + border-color: #ddd8eb; + font-family: 'Nunito Sans', sans-serif; + padding: 6px 12px; + background-color: white; + cursor: pointer; + transition: all 0.2s ease; +} + +.member-calendar .rbc-toolbar button:hover { + background-color: #f1eef9; + border-color: #664fa3; +} + +.member-calendar .rbc-toolbar button:active, +.member-calendar .rbc-toolbar button.rbc-active { + background-color: #664fa3; + color: white; +} + +.member-calendar .rbc-month-view { + border: 1px solid #ddd8eb; + border-radius: 8px; +} + +.member-calendar .rbc-day-bg { + border-color: #ddd8eb; +} + +.member-calendar .rbc-date-cell { + padding: 8px; + font-family: 'Nunito Sans', sans-serif; +} + +/* Ensure toolbar buttons are clickable */ +.member-calendar .rbc-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + gap: 12px; +} + +.member-calendar .rbc-toolbar button { + outline: none; + border: 1px solid #ddd8eb; + border-radius: 6px; + font-size: 14px; + font-weight: 500; +} + +.member-calendar .rbc-btn-group button { + margin: 0 2px; +} + +.member-calendar .rbc-btn-group button:first-child { + margin-left: 0; +} + +.member-calendar .rbc-btn-group button:last-child { + margin-right: 0; +} diff --git a/src/pages/members/MemberCalendar.js b/src/pages/members/MemberCalendar.js index 4a97f89..986fc18 100644 --- a/src/pages/members/MemberCalendar.js +++ b/src/pages/members/MemberCalendar.js @@ -2,6 +2,7 @@ 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 './MemberCalendar.css'; import api from '../../utils/api'; import Navbar from '../../components/Navbar'; import MemberFooter from '../../components/MemberFooter'; @@ -26,6 +27,8 @@ export default function MemberCalendar() { const [selectedEvent, setSelectedEvent] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const [rsvpLoading, setRsvpLoading] = useState(false); + const [currentDate, setCurrentDate] = useState(new Date()); + const [currentView, setCurrentView] = useState('month'); useEffect(() => { fetchEvents(); @@ -58,6 +61,14 @@ export default function MemberCalendar() { setIsDialogOpen(true); }; + const handleNavigate = (newDate) => { + setCurrentDate(newDate); + }; + + const handleViewChange = (newView) => { + setCurrentView(newView); + }; + const handleRSVP = async (status) => { if (!selectedEvent) return; @@ -171,10 +182,13 @@ export default function MemberCalendar() { startAccessor="start" endAccessor="end" style={{ height: 700 }} + date={currentDate} + view={currentView} + onNavigate={handleNavigate} + onView={handleViewChange} onSelectEvent={handleSelectEvent} eventPropGetter={eventStyleGetter} views={['month', 'week', 'day', 'agenda']} - defaultView="month" popup className="member-calendar" /> @@ -320,64 +334,6 @@ export default function MemberCalendar() { - ); diff --git a/src/utils/api.js b/src/utils/api.js index 7a1a797..b79e35b 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -4,14 +4,60 @@ const API_URL = process.env.REACT_APP_BACKEND_URL; export const api = axios.create({ baseURL: `${API_URL}/api`, + timeout: 30000, // 30 second timeout for all requests }); -api.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; +// Request interceptor - add auth token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + console.error('[API] Request error:', error); + return Promise.reject(error); } - return config; -}); +); + +// Response interceptor - handle errors and retries +api.interceptors.response.use( + (response) => { + return response; + }, + async (error) => { + const config = error.config; + + // Don't retry if we've already retried or if it's a client error (4xx) + if (!config || config.__isRetry || (error.response && error.response.status < 500)) { + console.error('[API] Request failed:', { + url: config?.url, + method: config?.method, + status: error.response?.status, + message: error.message, + data: error.response?.data + }); + return Promise.reject(error); + } + + // Mark as retry to prevent infinite loops + config.__isRetry = true; + + // Retry after 1 second for server errors or network issues + console.warn('[API] Retrying request after 1s:', { + url: config.url, + method: config.method, + error: error.message + }); + + return new Promise((resolve) => { + setTimeout(() => { + resolve(api.request(config)); + }, 1000); + }); + } +); export default api;