Update New Features

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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