Update New Features
This commit is contained in:
194
src/pages/members/Bylaws.js
Normal file
194
src/pages/members/Bylaws.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import MemberFooter from '../../components/MemberFooter';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { Scale, ExternalLink, History, Check } from 'lucide-react';
|
||||
|
||||
export default function Bylaws() {
|
||||
const [currentBylaws, setCurrentBylaws] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCurrentBylaws();
|
||||
fetchHistory();
|
||||
}, []);
|
||||
|
||||
const fetchCurrentBylaws = async () => {
|
||||
try {
|
||||
const response = await api.get('/bylaws/current');
|
||||
setCurrentBylaws(response.data);
|
||||
} catch (error) {
|
||||
if (error.response?.status !== 404) {
|
||||
toast.error('Failed to load current bylaws');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const response = await api.get('/bylaws/history');
|
||||
setHistory(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load bylaws history');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading bylaws...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
LOAF Bylaws
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review the official governing bylaws and policies of the LOAF community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Bylaws */}
|
||||
{currentBylaws ? (
|
||||
<Card className="p-8 bg-white rounded-2xl border-2 border-[#664fa3] mb-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-4 rounded-xl">
|
||||
<Scale className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{currentBylaws.title}
|
||||
</h2>
|
||||
<Badge className="bg-[#81B29A] text-white">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Current Version
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[#664fa3] mb-4">
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Version: <strong>{currentBylaws.version}</strong>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Effective: <strong>{formatDate(currentBylaws.effective_date)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.open(currentBylaws.document_url, '_blank')}
|
||||
size="lg"
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
View Current Bylaws
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-12 text-center bg-white rounded-2xl border border-[#ddd8eb] mb-6">
|
||||
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No current bylaws document available
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Version History Toggle */}
|
||||
{history.length > 1 && (
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
variant="outline"
|
||||
className="w-full border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9] rounded-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
{showHistory ? 'Hide' : 'View'} Version History ({history.length - 1} previous {history.length - 1 === 1 ? 'version' : 'versions'})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version History */}
|
||||
{showHistory && history.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Previous Versions
|
||||
</h3>
|
||||
{history.filter(b => !b.is_current).map(bylaws => (
|
||||
<Card key={bylaws.id} className="p-6 bg-[#f9f7fc] rounded-xl border border-[#ddd8eb]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{bylaws.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-sm text-[#664fa3]">
|
||||
<span>Version {bylaws.version}</span>
|
||||
<span>•</span>
|
||||
<span>Effective {formatDate(bylaws.effective_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.open(bylaws.document_url, '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Information Card */}
|
||||
<Card className="mt-8 p-6 bg-[#f9f7fc] border border-[#ddd8eb]">
|
||||
<div className="flex items-start gap-3">
|
||||
<Scale className="h-5 w-5 text-[#664fa3] mt-1" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
About LOAF Bylaws
|
||||
</h4>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The bylaws serve as the governing document for LOAF, outlining the organization's structure,
|
||||
membership requirements, officer responsibilities, and operational procedures. All members are
|
||||
encouraged to familiarize themselves with these guidelines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
349
src/pages/members/EventGallery.js
Normal file
349
src/pages/members/EventGallery.js
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import api from '../../utils/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import MemberFooter from '../../components/MemberFooter';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Dialog, DialogContent } from '../../components/ui/dialog';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Image as ImageIcon, Calendar, MapPin, ArrowLeft, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import moment from 'moment';
|
||||
|
||||
const EventGallery = () => {
|
||||
const { eventId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [galleryImages, setGalleryImages] = useState([]);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [galleryLoading, setGalleryLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchEventsWithGalleries();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventId) {
|
||||
fetchEventGallery(eventId);
|
||||
}
|
||||
}, [eventId]);
|
||||
|
||||
const fetchEventsWithGalleries = async () => {
|
||||
try {
|
||||
const response = await api.get('/members/gallery');
|
||||
setEvents(response.data);
|
||||
|
||||
// If there's an eventId in URL, find that event
|
||||
if (eventId) {
|
||||
const event = response.data.find(e => e.id === eventId);
|
||||
if (event) {
|
||||
setSelectedEvent(event);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch events:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load event galleries. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEventGallery = async (id) => {
|
||||
setGalleryLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/events/${id}/gallery`);
|
||||
setGalleryImages(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch gallery:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load gallery images. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setGalleryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventClick = (event) => {
|
||||
setSelectedEvent(event);
|
||||
navigate(`/members/gallery/${event.id}`);
|
||||
fetchEventGallery(event.id);
|
||||
};
|
||||
|
||||
const handleBackToEvents = () => {
|
||||
setSelectedEvent(null);
|
||||
setGalleryImages([]);
|
||||
navigate('/members/gallery');
|
||||
};
|
||||
|
||||
const openLightbox = (index) => {
|
||||
setSelectedImageIndex(index);
|
||||
};
|
||||
|
||||
const closeLightbox = () => {
|
||||
setSelectedImageIndex(null);
|
||||
};
|
||||
|
||||
const nextImage = () => {
|
||||
if (selectedImageIndex !== null) {
|
||||
setSelectedImageIndex((selectedImageIndex + 1) % galleryImages.length);
|
||||
}
|
||||
};
|
||||
|
||||
const previousImage = () => {
|
||||
if (selectedImageIndex !== null) {
|
||||
setSelectedImageIndex((selectedImageIndex - 1 + galleryImages.length) % galleryImages.length);
|
||||
}
|
||||
};
|
||||
|
||||
const EventCard = ({ event }) => (
|
||||
<Card
|
||||
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer h-full"
|
||||
onClick={() => handleEventClick(event)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative h-48 mb-4 rounded-xl overflow-hidden bg-[#F8F7FB]">
|
||||
{event.thumbnail_url ? (
|
||||
<img
|
||||
src={event.thumbnail_url}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ImageIcon className="h-16 w-16 text-[#ddd8eb]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-3 right-3">
|
||||
<Badge className="bg-[#664fa3] text-white px-3 py-1 rounded-full">
|
||||
{event.gallery_count} {event.gallery_count === 1 ? 'photo' : 'photos'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Info */}
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
{event.description && (
|
||||
<p className="text-[#664fa3] mb-3 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-[#664fa3]">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{moment(event.start_at).format('MMMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[#664fa3]">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Event Gallery Grid View
|
||||
if (!selectedEvent) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Event Gallery
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Browse photos from past LOAF events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Events Grid */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading galleries...</p>
|
||||
</div>
|
||||
) : events.length > 0 ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{events.map((event) => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<ImageIcon className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Event Galleries Yet
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Event photos will appear here once admins upload them.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual Event Gallery View
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Back Button */}
|
||||
<Button
|
||||
onClick={handleBackToEvents}
|
||||
variant="ghost"
|
||||
className="mb-6 text-[#664fa3] hover:text-[#422268] hover:bg-[#F8F7FB]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to All Galleries
|
||||
</Button>
|
||||
|
||||
{/* Event Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{selectedEvent.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-4 text-[#664fa3]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{moment(selectedEvent.start_at).format('MMMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{selectedEvent.location}</span>
|
||||
</div>
|
||||
<Badge className="bg-[#664fa3] text-white px-3 py-1 rounded-full">
|
||||
{selectedEvent.gallery_count} {selectedEvent.gallery_count === 1 ? 'photo' : 'photos'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery Grid */}
|
||||
{galleryLoading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading images...</p>
|
||||
</div>
|
||||
) : galleryImages.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{galleryImages.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative aspect-square rounded-xl overflow-hidden cursor-pointer group"
|
||||
onClick={() => openLightbox(index)}
|
||||
>
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt={image.caption || `Gallery image ${index + 1}`}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-8 w-8 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
{image.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-white text-sm line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{image.caption}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<ImageIcon className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Photos Yet
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Photos from this event will appear here once uploaded.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox Modal */}
|
||||
<Dialog open={selectedImageIndex !== null} onOpenChange={closeLightbox}>
|
||||
<DialogContent className="max-w-7xl w-full h-[90vh] p-0 bg-black border-0">
|
||||
{selectedImageIndex !== null && galleryImages[selectedImageIndex] && (
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
onClick={closeLightbox}
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/70 text-white rounded-full p-2"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
|
||||
{/* Previous Button */}
|
||||
{galleryImages.length > 1 && (
|
||||
<Button
|
||||
onClick={previousImage}
|
||||
variant="ghost"
|
||||
className="absolute left-4 z-50 bg-black/50 hover:bg-black/70 text-white rounded-full p-3"
|
||||
>
|
||||
<ChevronLeft className="h-8 w-8" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-8">
|
||||
<img
|
||||
src={galleryImages[selectedImageIndex].image_url}
|
||||
alt={galleryImages[selectedImageIndex].caption || `Image ${selectedImageIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
{galleryImages[selectedImageIndex].caption && (
|
||||
<div className="mt-4 max-w-2xl">
|
||||
<p className="text-white text-center text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{galleryImages[selectedImageIndex].caption}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-white/70 text-sm">
|
||||
{selectedImageIndex + 1} / {galleryImages.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
{galleryImages.length > 1 && (
|
||||
<Button
|
||||
onClick={nextImage}
|
||||
variant="ghost"
|
||||
className="absolute right-4 z-50 bg-black/50 hover:bg-black/70 text-white rounded-full p-3"
|
||||
>
|
||||
<ChevronRight className="h-8 w-8" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventGallery;
|
||||
125
src/pages/members/Financials.js
Normal file
125
src/pages/members/Financials.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import MemberFooter from '../../components/MemberFooter';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { DollarSign, ExternalLink, TrendingUp } from 'lucide-react';
|
||||
|
||||
export default function Financials() {
|
||||
const [reports, setReports] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
}, []);
|
||||
|
||||
const fetchReports = async () => {
|
||||
try {
|
||||
const response = await api.get('/financials');
|
||||
setReports(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load financial reports');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading financial reports...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Financial Reports
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Access annual financial reports and stay informed about LOAF's fiscal responsibility.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reports List */}
|
||||
{reports.length === 0 ? (
|
||||
<Card className="p-12 text-center bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No financial reports available yet
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{reports.map(report => (
|
||||
<Card key={report.id} className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Year Badge */}
|
||||
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-6 rounded-xl text-white min-w-[120px] text-center">
|
||||
<DollarSign className="h-8 w-8 mx-auto mb-2" />
|
||||
<div className="text-3xl font-bold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{report.year}
|
||||
</div>
|
||||
<div className="text-sm opacity-90">Fiscal Year</div>
|
||||
</div>
|
||||
|
||||
{/* Report Details */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{report.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
||||
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.open(report.document_url, '_blank')}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transparency Note */}
|
||||
{reports.length > 0 && (
|
||||
<Card className="mt-8 p-6 bg-[#f9f7fc] border border-[#ddd8eb]">
|
||||
<div className="flex items-start gap-3">
|
||||
<TrendingUp className="h-5 w-5 text-[#664fa3] mt-1" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Transparency & Accountability
|
||||
</h4>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF is committed to financial transparency. These reports provide detailed information about our
|
||||
revenue, expenses, and how member contributions support our community programs and operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
384
src/pages/members/MemberCalendar.js
Normal file
384
src/pages/members/MemberCalendar.js
Normal file
@@ -0,0 +1,384 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
||||
import moment from 'moment';
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||
import api from '../../utils/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import MemberFooter from '../../components/MemberFooter';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import AddToCalendarButton from '../../components/AddToCalendarButton';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../components/ui/dialog';
|
||||
import { Calendar as CalendarIcon, MapPin, Users, Clock, X, Check, HelpCircle } from 'lucide-react';
|
||||
|
||||
const localizer = momentLocalizer(moment);
|
||||
|
||||
export default function MemberCalendar() {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [rsvpLoading, setRsvpLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await api.get('/events');
|
||||
setEvents(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Transform events for react-big-calendar
|
||||
const calendarEvents = useMemo(() => {
|
||||
return events.map(event => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: new Date(event.start_at),
|
||||
end: new Date(event.end_at),
|
||||
resource: event,
|
||||
}));
|
||||
}, [events]);
|
||||
|
||||
const handleSelectEvent = (event) => {
|
||||
setSelectedEvent(event.resource);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRSVP = async (status) => {
|
||||
if (!selectedEvent) return;
|
||||
|
||||
setRsvpLoading(true);
|
||||
try {
|
||||
await api.post(`/events/${selectedEvent.id}/rsvp`, { rsvp_status: status });
|
||||
toast.success(`RSVP updated to: ${status}`);
|
||||
|
||||
await fetchEvents();
|
||||
|
||||
const updatedEvent = events.find(e => e.id === selectedEvent.id);
|
||||
if (updatedEvent) {
|
||||
setSelectedEvent({ ...updatedEvent, user_rsvp_status: status });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update RSVP');
|
||||
} finally {
|
||||
setRsvpLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const eventStyleGetter = (event) => {
|
||||
const rsvpStatus = event.resource?.user_rsvp_status;
|
||||
|
||||
let backgroundColor = '#DDD8EB';
|
||||
let borderColor = '#664fa3';
|
||||
|
||||
if (rsvpStatus === 'yes') {
|
||||
backgroundColor = '#81B29A';
|
||||
borderColor = '#66927e';
|
||||
} else if (rsvpStatus === 'no') {
|
||||
backgroundColor = '#9ca3af';
|
||||
borderColor = '#6b7280';
|
||||
} else if (rsvpStatus === 'maybe') {
|
||||
backgroundColor = '#fb923c';
|
||||
borderColor = '#ea580c';
|
||||
}
|
||||
|
||||
return {
|
||||
style: {
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid',
|
||||
borderRadius: '6px',
|
||||
color: 'white',
|
||||
fontWeight: '500',
|
||||
fontSize: '0.875rem',
|
||||
padding: '2px 6px',
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading calendar...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Event Calendar
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage your event RSVPs. Click on any event to see details and update your RSVP.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 flex-wrap items-center">
|
||||
<AddToCalendarButton
|
||||
showSubscribe={true}
|
||||
variant="default"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 ml-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-[#81B29A]"></div>
|
||||
<span className="text-sm text-[#664fa3]">Going</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-[#fb923c]"></div>
|
||||
<span className="text-sm text-[#664fa3]">Maybe</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-[#9ca3af]"></div>
|
||||
<span className="text-sm text-[#664fa3]">Not Going</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-[#DDD8EB]"></div>
|
||||
<span className="text-sm text-[#664fa3]">No RSVP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={calendarEvents}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
style={{ height: 700 }}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
eventPropGetter={eventStyleGetter}
|
||||
views={['month', 'week', 'day', 'agenda']}
|
||||
defaultView="month"
|
||||
popup
|
||||
className="member-calendar"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
{selectedEvent && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<CalendarIcon className="h-6 w-6 text-[#664fa3]" />
|
||||
</div>
|
||||
{selectedEvent.user_rsvp_status && (
|
||||
<Badge
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
selectedEvent.user_rsvp_status === 'yes'
|
||||
? 'bg-[#81B29A] text-white'
|
||||
: selectedEvent.user_rsvp_status === 'no'
|
||||
? 'bg-gray-400 text-white'
|
||||
: 'bg-orange-400 text-white'
|
||||
}`}
|
||||
>
|
||||
{selectedEvent.user_rsvp_status === 'yes' && 'Going'}
|
||||
{selectedEvent.user_rsvp_status === 'no' && 'Not Going'}
|
||||
{selectedEvent.user_rsvp_status === 'maybe' && 'Maybe'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{selectedEvent.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(selectedEvent.start_at).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<Clock className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(selectedEvent.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - {new Date(selectedEvent.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{selectedEvent.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<Users className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedEvent.rsvp_count || 0} {selectedEvent.rsvp_count === 1 ? 'person' : 'people'} attending
|
||||
{selectedEvent.capacity && ` (Capacity: ${selectedEvent.capacity})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEvent.description && (
|
||||
<div className="pt-4 border-t border-[#ddd8eb]">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
About This Event
|
||||
</h3>
|
||||
<p className="text-[#664fa3] leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedEvent.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-[#ddd8eb]">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Your RSVP
|
||||
</h3>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button
|
||||
onClick={() => handleRSVP('yes')}
|
||||
disabled={rsvpLoading}
|
||||
size="sm"
|
||||
className={`rounded-full px-6 flex items-center gap-2 ${
|
||||
selectedEvent.user_rsvp_status === 'yes'
|
||||
? 'bg-[#81B29A] text-white hover:bg-[#66927e]'
|
||||
: 'bg-[#DDD8EB] text-[#422268] hover:bg-[#c4bed8]'
|
||||
}`}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
I'm Going
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRSVP('maybe')}
|
||||
disabled={rsvpLoading}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`rounded-full px-6 flex items-center gap-2 border-2 ${
|
||||
selectedEvent.user_rsvp_status === 'maybe'
|
||||
? 'border-orange-400 bg-orange-100 text-orange-700'
|
||||
: 'border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9]'
|
||||
}`}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Maybe
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRSVP('no')}
|
||||
disabled={rsvpLoading}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`rounded-full px-6 flex items-center gap-2 border-2 ${
|
||||
selectedEvent.user_rsvp_status === 'no'
|
||||
? 'border-gray-400 bg-gray-100 text-gray-700'
|
||||
: 'border-gray-400 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Can't Attend
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#ddd8eb]">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Add to Your Calendar
|
||||
</h3>
|
||||
<AddToCalendarButton
|
||||
event={selectedEvent}
|
||||
showSubscribe={false}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.member-calendar .rbc-header {
|
||||
padding: 12px 6px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
color: #422268;
|
||||
background-color: #f9f7fc;
|
||||
border-bottom: 2px solid #ddd8eb;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-today {
|
||||
background-color: #f1eef9;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-off-range-bg {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-event {
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-event:hover {
|
||||
opacity: 0.85;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button {
|
||||
color: #664fa3;
|
||||
border-color: #ddd8eb;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button:hover {
|
||||
background-color: #f1eef9;
|
||||
border-color: #664fa3;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button.rbc-active {
|
||||
background-color: #664fa3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-month-view {
|
||||
border: 1px solid #ddd8eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-day-bg {
|
||||
border-color: #ddd8eb;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-date-cell {
|
||||
padding: 8px;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
`}</style>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
490
src/pages/members/MemberProfile.js
Normal file
490
src/pages/members/MemberProfile.js
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import MemberFooter from '../../components/MemberFooter';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import { User, Upload, X, Facebook, Instagram, Twitter, Linkedin, Camera, Save, Eye } from 'lucide-react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
const MemberProfile = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
social_media_facebook: '',
|
||||
social_media_instagram: '',
|
||||
social_media_twitter: '',
|
||||
social_media_linkedin: '',
|
||||
show_in_directory: false,
|
||||
directory_email: '',
|
||||
directory_bio: '',
|
||||
directory_address: '',
|
||||
directory_phone: '',
|
||||
directory_partner_name: ''
|
||||
});
|
||||
const [previewImage, setPreviewImage] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await api.get('/members/profile');
|
||||
setProfile(response.data);
|
||||
|
||||
// Populate form with existing data
|
||||
setFormData({
|
||||
social_media_facebook: response.data.social_media_facebook || '',
|
||||
social_media_instagram: response.data.social_media_instagram || '',
|
||||
social_media_twitter: response.data.social_media_twitter || '',
|
||||
social_media_linkedin: response.data.social_media_linkedin || '',
|
||||
show_in_directory: response.data.show_in_directory || false,
|
||||
directory_email: response.data.directory_email || '',
|
||||
directory_bio: response.data.directory_bio || '',
|
||||
directory_address: response.data.directory_address || '',
|
||||
directory_phone: response.data.directory_phone || '',
|
||||
directory_partner_name: response.data.directory_partner_name || ''
|
||||
});
|
||||
|
||||
if (response.data.profile_photo_url) {
|
||||
setPreviewImage(response.data.profile_photo_url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profile:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load profile. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSwitchChange = (checked) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
show_in_directory: checked
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePhotoUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: "Invalid File",
|
||||
description: "Please upload an image file (JPG, PNG, WebP, or GIF).",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (50MB max)
|
||||
if (file.size > 52428800) {
|
||||
toast({
|
||||
title: "File Too Large",
|
||||
description: "Image must be smaller than 50MB.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post('/members/profile/upload-photo', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
setPreviewImage(response.data.profile_photo_url);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Profile photo uploaded successfully!"
|
||||
});
|
||||
|
||||
// Refresh profile
|
||||
await fetchProfile();
|
||||
} catch (error) {
|
||||
console.error('Failed to upload photo:', error);
|
||||
toast({
|
||||
title: "Upload Failed",
|
||||
description: error.response?.data?.detail || "Failed to upload photo. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePhoto = async () => {
|
||||
if (!window.confirm('Are you sure you want to delete your profile photo?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete('/members/profile/delete-photo');
|
||||
setPreviewImage(null);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Profile photo deleted successfully."
|
||||
});
|
||||
|
||||
await fetchProfile();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete photo:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete photo. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await api.put('/members/profile', formData);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Profile updated successfully!"
|
||||
});
|
||||
|
||||
await fetchProfile();
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update profile. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Member Profile
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Enhance your profile with a photo and social media links.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Profile Photo Section */}
|
||||
<Card className="p-8 bg-white border-[#ddd8eb] rounded-2xl">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Profile Photo
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-8">
|
||||
<div className="relative">
|
||||
{previewImage ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="Profile"
|
||||
className="w-40 h-40 rounded-full object-cover border-4 border-[#ddd8eb]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDeletePhoto}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-2 w-8 h-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-40 h-40 rounded-full bg-[#F8F7FB] border-4 border-[#ddd8eb] flex items-center justify-center">
|
||||
<User className="h-20 w-20 text-[#ddd8eb]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handlePhotoUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl px-6 py-3 flex items-center gap-2"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Upload className="h-5 w-5 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Camera className="h-5 w-5" />
|
||||
{previewImage ? 'Change Photo' : 'Upload Photo'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-sm text-[#664fa3] mt-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
JPG, PNG, WebP, or GIF. Max 50MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Social Media Section */}
|
||||
<Card className="p-8 bg-white border-[#ddd8eb] rounded-2xl">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Social Media Links
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Facebook className="h-4 w-4 text-[#1877F2]" />
|
||||
Facebook Profile URL
|
||||
</Label>
|
||||
<Input
|
||||
type="url"
|
||||
name="social_media_facebook"
|
||||
value={formData.social_media_facebook}
|
||||
onChange={handleInputChange}
|
||||
placeholder="https://facebook.com/yourprofile"
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Instagram className="h-4 w-4 text-[#E4405F]" />
|
||||
Instagram Profile URL
|
||||
</Label>
|
||||
<Input
|
||||
type="url"
|
||||
name="social_media_instagram"
|
||||
value={formData.social_media_instagram}
|
||||
onChange={handleInputChange}
|
||||
placeholder="https://instagram.com/yourprofile"
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Twitter className="h-4 w-4 text-[#1DA1F2]" />
|
||||
Twitter/X Profile URL
|
||||
</Label>
|
||||
<Input
|
||||
type="url"
|
||||
name="social_media_twitter"
|
||||
value={formData.social_media_twitter}
|
||||
onChange={handleInputChange}
|
||||
placeholder="https://twitter.com/yourprofile"
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Linkedin className="h-4 w-4 text-[#0A66C2]" />
|
||||
LinkedIn Profile URL
|
||||
</Label>
|
||||
<Input
|
||||
type="url"
|
||||
name="social_media_linkedin"
|
||||
value={formData.social_media_linkedin}
|
||||
onChange={handleInputChange}
|
||||
placeholder="https://linkedin.com/in/yourprofile"
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Directory Settings Section */}
|
||||
<Card className="p-8 bg-white border-[#ddd8eb] rounded-2xl">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Directory Settings
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-[#F8F7FB] rounded-xl">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[#422268] font-medium flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Eye className="h-4 w-4 text-[#664fa3]" />
|
||||
Show in Members Directory
|
||||
</Label>
|
||||
<p className="text-sm text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Allow other members to see your profile in the directory
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.show_in_directory}
|
||||
onCheckedChange={handleSwitchChange}
|
||||
className="data-[state=checked]:bg-[#664fa3]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.show_in_directory && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Directory Email (visible to members)
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
name="directory_email"
|
||||
value={formData.directory_email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="public.email@example.com"
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Bio (visible to members)
|
||||
</Label>
|
||||
<Textarea
|
||||
name="directory_bio"
|
||||
value={formData.directory_bio}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Tell other members about yourself..."
|
||||
rows={4}
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Directory Address (optional)
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="directory_address"
|
||||
value={formData.directory_address}
|
||||
onChange={handleInputChange}
|
||||
placeholder="123 Main St, City, State"
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Directory Phone (optional)
|
||||
</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
name="directory_phone"
|
||||
value={formData.directory_phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="(555) 123-4567"
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Partner Name (if applicable)
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="directory_partner_name"
|
||||
value={formData.directory_partner_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Partner's name"
|
||||
className="border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-[#ff9e77] hover:bg-[#ff8c5a] text-white rounded-xl px-8 py-3 text-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{saving ? (
|
||||
'Saving...'
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
Save Profile
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberProfile;
|
||||
295
src/pages/members/MembersDirectory.js
Normal file
295
src/pages/members/MembersDirectory.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import MemberFooter from '../../components/MemberFooter';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin } from 'lucide-react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
const MembersDirectory = () => {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [filteredMembers, setFilteredMembers] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterMembers();
|
||||
}, [searchQuery, members]);
|
||||
|
||||
const fetchMembers = async () => {
|
||||
try {
|
||||
const response = await api.get('/members/directory');
|
||||
setMembers(response.data);
|
||||
setFilteredMembers(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch members:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load members directory. Please try again.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterMembers = () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredMembers(members);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = members.filter(member => {
|
||||
const fullName = `${member.first_name} ${member.last_name}`.toLowerCase();
|
||||
const bio = (member.directory_bio || '').toLowerCase();
|
||||
return fullName.includes(query) || bio.includes(query);
|
||||
});
|
||||
|
||||
setFilteredMembers(filtered);
|
||||
};
|
||||
|
||||
const getInitials = (firstName, lastName) => {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
};
|
||||
|
||||
const getSocialMediaLink = (url) => {
|
||||
if (!url) return null;
|
||||
// Ensure URL has protocol
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return `https://${url}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const MemberCard = ({ member }) => (
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
|
||||
{/* Profile Photo */}
|
||||
<div className="flex justify-center mb-4">
|
||||
{member.profile_photo_url ? (
|
||||
<img
|
||||
src={member.profile_photo_url}
|
||||
alt={`${member.first_name} ${member.last_name}`}
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-[#ddd8eb]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-[#DDD8EB] border-4 border-[#ddd8eb] flex items-center justify-center">
|
||||
<span className="text-4xl font-semibold text-[#664fa3]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{getInitials(member.first_name, member.last_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="text-2xl font-semibold text-[#422268] text-center mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{member.first_name} {member.last_name}
|
||||
</h3>
|
||||
|
||||
{/* Partner Name */}
|
||||
{member.directory_partner_name && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Heart className="h-4 w-4 text-[#ff9e77]" />
|
||||
<span className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Partner: {member.directory_partner_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bio */}
|
||||
{member.directory_bio && (
|
||||
<p className="text-[#664fa3] text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{member.directory_bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{member.directory_email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-[#664fa3] flex-shrink-0" />
|
||||
<a
|
||||
href={`mailto:${member.directory_email}`}
|
||||
className="text-[#664fa3] hover:text-[#422268] truncate"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{member.directory_email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{member.directory_phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-[#664fa3] flex-shrink-0" />
|
||||
<a
|
||||
href={`tel:${member.directory_phone}`}
|
||||
className="text-[#664fa3] hover:text-[#422268]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{member.directory_phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{member.directory_address && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 text-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{member.directory_address}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Social Media Links */}
|
||||
{(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
|
||||
<div className="pt-4 border-t border-[#ddd8eb]">
|
||||
<div className="flex justify-center gap-3">
|
||||
{member.social_media_facebook && (
|
||||
<a
|
||||
href={getSocialMediaLink(member.social_media_facebook)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Facebook"
|
||||
>
|
||||
<Facebook className="h-5 w-5 text-[#1877F2]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.social_media_instagram && (
|
||||
<a
|
||||
href={getSocialMediaLink(member.social_media_instagram)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Instagram"
|
||||
>
|
||||
<Instagram className="h-5 w-5 text-[#E4405F]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.social_media_twitter && (
|
||||
<a
|
||||
href={getSocialMediaLink(member.social_media_twitter)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Twitter/X"
|
||||
>
|
||||
<Twitter className="h-5 w-5 text-[#1DA1F2]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.social_media_linkedin && (
|
||||
<a
|
||||
href={getSocialMediaLink(member.social_media_linkedin)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<Linkedin className="h-5 w-5 text-[#0A66C2]" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Directory
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Connect with fellow LOAF members in our community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="relative max-w-xl">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name or bio..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-12 pr-4 py-6 text-lg border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<p className="mt-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members Grid */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading members...</p>
|
||||
</div>
|
||||
) : filteredMembers.length > 0 ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredMembers.map((member) => (
|
||||
<MemberCard key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<User className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{searchQuery ? 'No Members Found' : 'No Members in Directory'}
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery
|
||||
? 'Try adjusting your search query.'
|
||||
: 'Members who opt in to the directory will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
{!loading && members.length > 0 && (
|
||||
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<User className="h-6 w-6 text-[#664fa3]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Want to appear in the directory?
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '}
|
||||
<a href="/members/profile" className="text-[#ff9e77] hover:underline font-medium">
|
||||
Edit your profile →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersDirectory;
|
||||
210
src/pages/members/NewsletterArchive.js
Normal file
210
src/pages/members/NewsletterArchive.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import MemberFooter from '../../components/MemberFooter';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, ExternalLink, Calendar, Search } from 'lucide-react';
|
||||
|
||||
export default function NewsletterArchive() {
|
||||
const [newsletters, setNewsletters] = useState([]);
|
||||
const [years, setYears] = useState([]);
|
||||
const [selectedYear, setSelectedYear] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchYears();
|
||||
fetchNewsletters();
|
||||
}, []);
|
||||
|
||||
const fetchYears = async () => {
|
||||
try {
|
||||
const response = await api.get('/newsletters/years');
|
||||
setYears(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load years');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNewsletters = async (year = null) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = year ? `/newsletters?year=${year}` : '/newsletters';
|
||||
const response = await api.get(url);
|
||||
setNewsletters(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load newsletters');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleYearFilter = (year) => {
|
||||
setSelectedYear(year);
|
||||
fetchNewsletters(year);
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
setSelectedYear(null);
|
||||
fetchNewsletters();
|
||||
};
|
||||
|
||||
const filteredNewsletters = newsletters.filter(newsletter =>
|
||||
newsletter.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(newsletter.description && newsletter.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const groupByYear = (newsletters) => {
|
||||
const grouped = {};
|
||||
newsletters.forEach(newsletter => {
|
||||
const year = new Date(newsletter.published_date).getFullYear();
|
||||
if (!grouped[year]) {
|
||||
grouped[year] = [];
|
||||
}
|
||||
grouped[year].push(newsletter);
|
||||
});
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const groupedNewsletters = groupByYear(filteredNewsletters);
|
||||
const sortedYears = Object.keys(groupedNewsletters).sort((a, b) => b - a);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading newsletters...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Newsletter Archive
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Browse past monthly newsletters and stay informed about LOAF community updates.
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4 flex-wrap items-center">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[#664fa3]" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search newsletters..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Year Filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={clearFilter}
|
||||
variant={selectedYear === null ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={selectedYear === null ? "bg-[#664fa3] text-white" : "border-[#664fa3] text-[#664fa3]"}
|
||||
>
|
||||
All Years
|
||||
</Button>
|
||||
{years.map(year => (
|
||||
<Button
|
||||
key={year}
|
||||
onClick={() => handleYearFilter(year)}
|
||||
variant={selectedYear === year ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={selectedYear === year ? "bg-[#664fa3] text-white" : "border-[#664fa3] text-[#664fa3]"}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Newsletter List */}
|
||||
{filteredNewsletters.length === 0 ? (
|
||||
<Card className="p-12 text-center bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No newsletters found
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sortedYears.map(year => (
|
||||
<div key={year}>
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Calendar className="h-6 w-6" />
|
||||
{year}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{groupedNewsletters[year].map(newsletter => (
|
||||
<Card key={newsletter.id} className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg flex-shrink-0">
|
||||
<FileText className="h-6 w-6 text-[#664fa3]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{newsletter.title}
|
||||
</h3>
|
||||
{newsletter.description && (
|
||||
<p className="text-[#664fa3] mb-3 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{newsletter.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Badge className="bg-[#DDD8EB] text-[#422268] hover:bg-[#DDD8EB]">
|
||||
{formatDate(newsletter.published_date)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
||||
{newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.open(newsletter.document_url, '_blank')}
|
||||
className="w-full bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View Newsletter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user