forked from andika/membership-fe
Update New Features
This commit is contained in:
241
src/pages/BecomeMember.js
Normal file
241
src/pages/BecomeMember.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../components/ui/button';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
|
||||
const BecomeMember = () => {
|
||||
// Image assets from Figma
|
||||
const imgIconAdminFee1 = "https://www.figma.com/api/mcp/asset/e4e0af2e-b461-4f68-b8c7-2e49a94095d4";
|
||||
const imgIconAdminFee2 = "https://www.figma.com/api/mcp/asset/df5b86ce-18c0-470e-8ea3-f6af3dabbd9d";
|
||||
const imgIconAdminFee3 = "https://www.figma.com/api/mcp/asset/ad064775-46ab-4b54-883f-d09757971162";
|
||||
const imgIconAdminFee4 = "https://www.figma.com/api/mcp/asset/7fac9483-07ff-4cfd-8ea6-ab0509ef62a9";
|
||||
const imgIconAdminFee5 = "https://www.figma.com/api/mcp/asset/b89a44fe-a041-4611-9e2d-fa88d9223204";
|
||||
const imgShootingStar = "https://www.figma.com/api/mcp/asset/f1afecdc-d65c-4787-8672-8fa02f66e4c3";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 relative">
|
||||
<PublicNavbar />
|
||||
|
||||
{/* Decorative shooting star element */}
|
||||
<div className="absolute left-[88px] top-[974px] w-[195px] h-[1135px] pointer-events-none opacity-50">
|
||||
<img
|
||||
src={imgShootingStar}
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-gray-50 pt-20 pb-24">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h1
|
||||
className="text-[48px] font-bold text-[#48286e] mb-8 leading-[1.2] tracking-[-0.96px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Become a Member
|
||||
</h1>
|
||||
<p
|
||||
className="text-[19px] font-medium text-[#48286e] max-w-[689px] mx-auto leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Become a member to receive our monthly newsletter and find out about all the activities LOAF has planned each month. LOAF hosts over 40 social activities each year and occasionally covers the costs for members only
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annual Administrative Fees Section */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-16">
|
||||
<div className="flex gap-5 items-center">
|
||||
<div className="w-[153px] h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee1}
|
||||
alt="Admin Fee Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-[#eeebf4] rounded-[25px] px-8 py-8">
|
||||
<h3
|
||||
className="text-[32px] font-semibold text-[#48286e] mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Annual Administrative Fees
|
||||
</h3>
|
||||
<p
|
||||
className="text-[19px] font-medium text-[#48286e] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Annual Administrative Fees for all members are $30 per person. These fees help cover general business expenses (website, advertising, e-newsletter).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Membership Process Section */}
|
||||
<div className="relative bg-gray-50 py-16">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h2
|
||||
className="text-[40px] font-bold text-[#48286e] mb-8 leading-[1.2] tracking-[-0.8px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Membership Process
|
||||
</h2>
|
||||
<p
|
||||
className="text-[19px] font-medium text-[#48286e] max-w-[689px] mx-auto leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Becoming a member is easy, but for the safety and privacy of our membership, there are a few steps:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1 */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-8">
|
||||
<div className="flex gap-5 items-center">
|
||||
<div className="w-[153px] h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee2}
|
||||
alt="Step 1 Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-8 py-8">
|
||||
<h3
|
||||
className="text-[32px] font-semibold text-[#48286e] mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 1: Application & Email Confirmation
|
||||
</h3>
|
||||
<p
|
||||
className="text-[19px] font-medium text-[#48286e] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are approved by an admin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Down Icon */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-8">
|
||||
<div className="flex gap-5 items-center">
|
||||
<div className="w-[153px] h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee3}
|
||||
alt="Step 2 Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-8 py-8">
|
||||
<h3
|
||||
className="text-[32px] font-semibold text-[#48286e] mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 2: Attend an event and meet us!
|
||||
</h3>
|
||||
<p
|
||||
className="text-[19px] font-medium text-[#48286e] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
You have 3 months to attend a LOAF event and introduce yourself to a board member. If you do not attend an event within 3 months, you will no longer receive the e-newsletter. (This step can be skipped if you have been referred from a current member and list her on your registration form).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Down Icon */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-8">
|
||||
<div className="flex gap-5 items-center">
|
||||
<div className="w-[153px] h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee4}
|
||||
alt="Step 3 Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-8 py-8">
|
||||
<h3
|
||||
className="text-[32px] font-semibold text-[#48286e] mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 3: Login and pay the annual fee
|
||||
</h3>
|
||||
<p
|
||||
className="text-[19px] font-medium text-[#48286e] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Once we know that you are indeed you, an admin will approve your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Down Icon */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
{/* Step 4 - With Gradient Background */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-16">
|
||||
<div className="flex gap-5 items-center">
|
||||
<div className="w-[153px] h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee5}
|
||||
alt="Step 4 Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-gradient-to-r from-[#48286e] to-[#664fa3] rounded-[25px] px-8 py-8">
|
||||
<h3
|
||||
className="text-[32px] font-semibold text-white mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 4: Welcome to LOAF!
|
||||
</h3>
|
||||
<p
|
||||
className="text-[19px] font-medium text-white leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Congratulations! Your application is complete, and you now have access to Members Only content. We hope to see you at future events soon!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="relative bg-gray-50 py-16">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h2
|
||||
className="text-[40px] font-bold text-[#48286e] mb-8 leading-[1.2] tracking-[-0.8px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Ready to Join Us?
|
||||
</h2>
|
||||
<Link to="/register">
|
||||
<Button
|
||||
className="bg-[#664fa3] text-white hover:bg-[#48286e] rounded-[35px] px-16 py-6 text-[18px] font-medium tracking-[-0.09px] h-[50px]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Register Now!
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BecomeMember;
|
||||
@@ -6,11 +6,12 @@ import { Card } from '../components/ui/card';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import Navbar from '../components/Navbar';
|
||||
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail } from 'lucide-react';
|
||||
import MemberFooter from '../components/MemberFooter';
|
||||
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user, resendVerificationEmail } = useAuth();
|
||||
const { user, resendVerificationEmail, refreshUser } = useAuth();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
@@ -38,7 +39,18 @@ const Dashboard = () => {
|
||||
toast.success('Verification email sent! Please check your inbox.');
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
|
||||
toast.error(errorMessage);
|
||||
|
||||
// If email is already verified, refresh user data to update UI
|
||||
if (errorMessage === 'Email is already verified') {
|
||||
try {
|
||||
await refreshUser();
|
||||
toast.success('Your email is already verified!');
|
||||
} catch (refreshError) {
|
||||
toast.error('Email is already verified. Please refresh the page.');
|
||||
}
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
@@ -168,6 +180,22 @@ const Dashboard = () => {
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
{user?.subscription_start_date && user?.subscription_end_date && (
|
||||
<>
|
||||
<div className="pt-4 border-t border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -265,6 +293,7 @@ const Dashboard = () => {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from '../components/ui/button';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import Navbar from '../components/Navbar';
|
||||
import MemberFooter from '../components/MemberFooter';
|
||||
import AddToCalendarButton from '../components/AddToCalendarButton';
|
||||
import { Calendar, MapPin, Users, ArrowLeft, Check, X, HelpCircle } from 'lucide-react';
|
||||
|
||||
const EventDetails = () => {
|
||||
@@ -191,9 +193,25 @@ const EventDetails = () => {
|
||||
Can't Attend
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add to Calendar Section */}
|
||||
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
|
||||
<h2 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Add to Your Calendar
|
||||
</h2>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Never miss this event! Add it to your calendar app for reminders.
|
||||
</p>
|
||||
<AddToCalendarButton
|
||||
event={event}
|
||||
showSubscribe={false}
|
||||
variant="outline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import api from '../utils/api';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import Navbar from '../components/Navbar';
|
||||
import MemberFooter from '../components/MemberFooter';
|
||||
import { Calendar, MapPin, Users, ArrowRight } from 'lucide-react';
|
||||
|
||||
const Events = () => {
|
||||
@@ -125,6 +126,7 @@ const Events = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import Navbar from '../components/Navbar';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
import { ArrowRight, ArrowLeft, Mail, CheckCircle } from 'lucide-react';
|
||||
|
||||
const ForgotPassword = () => {
|
||||
@@ -32,7 +33,7 @@ const ForgotPassword = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
@@ -114,6 +115,8 @@ const ForgotPassword = () => {
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,56 +2,21 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
|
||||
const Landing = () => {
|
||||
// LOAF brand assets from Figma
|
||||
const loafLogo = "https://www.figma.com/api/mcp/asset/5e3c057c-ffb0-4ceb-aa60-70a5ddbe10ab";
|
||||
const taglineImage = "https://www.figma.com/api/mcp/asset/ce184873-0c46-45eb-8480-76a9411011bf";
|
||||
const shootingStar = "https://www.figma.com/api/mcp/asset/6db528f4-493b-4560-9ff6-076bc42421ca";
|
||||
const iconMeetGreet = "https://www.figma.com/api/mcp/asset/3519deee-3f78-45e1-b537-f9c61102c18b";
|
||||
const iconSocials = "https://www.figma.com/api/mcp/asset/e95f23c6-e893-472e-a3cf-38a05290790b";
|
||||
const iconActive = "https://www.figma.com/api/mcp/asset/cab1679a-a7d6-4a5f-8732-c6c1cd578320";
|
||||
const heroLoaf = "https://www.figma.com/api/mcp/asset/f3fb05af-0fef-49b7-bc50-f5914dfe1705";
|
||||
// LOAF brand assets (local)
|
||||
const taglineImage = `${process.env.PUBLIC_URL}/tagline-image.png`;
|
||||
const shootingStar = `${process.env.PUBLIC_URL}/shooting-star.png`;
|
||||
const iconMeetGreet = `${process.env.PUBLIC_URL}/icon-meet-greet.png`;
|
||||
const iconSocials = `${process.env.PUBLIC_URL}/icon-socials.png`;
|
||||
const iconActive = `${process.env.PUBLIC_URL}/icon-active.png`;
|
||||
const heroLoaf = `${process.env.PUBLIC_URL}/hero-loaf.png`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Top Header - Auth Actions */}
|
||||
<header className="bg-gradient-to-r from-[#644c9f] to-[#48286e] px-16 py-4 flex justify-end items-center gap-6">
|
||||
<Link to="/register" className="text-white text-base font-medium hover:opacity-80 transition-opacity">
|
||||
Register
|
||||
</Link>
|
||||
<Link to="/login" className="text-white text-base font-medium hover:opacity-80 transition-opacity">
|
||||
Login
|
||||
</Link>
|
||||
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#644c9f] rounded-full px-6 py-3 text-base font-medium">
|
||||
Donate
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* Main Header - Navigation */}
|
||||
<header className="bg-[#664fa3] px-16 py-2 flex justify-between items-center">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="h-28 w-28 object-contain" />
|
||||
<nav className="flex gap-10 items-center">
|
||||
<a href="#welcome" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
|
||||
Welcome
|
||||
</a>
|
||||
<a href="#about" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
|
||||
About Us
|
||||
</a>
|
||||
<Link to="/register" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
|
||||
Become a Member
|
||||
</Link>
|
||||
<Link to="/login" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
|
||||
Members Only
|
||||
</Link>
|
||||
<a href="#resources" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
|
||||
Resources
|
||||
</a>
|
||||
<a href="#contact" className="text-white text-lg font-bold hover:opacity-80 transition-opacity" style={{ fontFamily: "'Nunito Sans', sans-serif", textShadow: '0px 4px 4px rgba(0,0,0,0.25)' }}>
|
||||
Contact Us
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
<PublicNavbar />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-b from-[#48286e] to-[#664fa3] px-16 py-0 flex gap-16 items-center justify-center">
|
||||
@@ -144,63 +109,7 @@ const Landing = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Footer */}
|
||||
<footer className="bg-[#644c9f] px-16 py-0 flex items-center justify-center h-[420px]">
|
||||
<div className="border-t border-[rgba(0,0,0,0.1)] py-20 flex gap-30 items-center justify-center flex-1">
|
||||
<div className="w-[232px]">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="w-[232px] h-[232px] object-contain" />
|
||||
</div>
|
||||
<nav className="flex gap-28 items-start justify-center w-[811px]">
|
||||
<div className="flex flex-col gap-2 w-[163px]">
|
||||
<div className="pb-4">
|
||||
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>About</p>
|
||||
</div>
|
||||
<a href="#history" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>History</a>
|
||||
<a href="#mission" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Mission and Values</a>
|
||||
<a href="#board" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Board of Directors</a>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-[148px]">
|
||||
<div className="pb-4">
|
||||
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Connect</p>
|
||||
</div>
|
||||
<Link to="/register" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Become a Member</Link>
|
||||
<a href="#contact" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Contact Us</a>
|
||||
<a href="#resources" className="text-[#ddd8eb] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Resources</a>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center w-[271px]">
|
||||
<div className="pb-4 w-full">
|
||||
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-full px-6 py-3 text-lg font-medium w-[217px]">
|
||||
Donate
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[#ddd8eb] text-base font-medium text-center w-full" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
LOAF is supported by<br />the Hollyfield Foundation
|
||||
</p>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Bottom Footer */}
|
||||
<footer className="bg-gradient-to-r from-[#48286e] to-[#644c9f] border-t border-[rgba(0,0,0,0.1)] px-16 py-6 flex justify-between items-center h-[76px]">
|
||||
<nav className="flex gap-8 items-center">
|
||||
<a href="#terms" className="text-[#c5b4e3] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Terms of Service
|
||||
</a>
|
||||
<a href="#privacy" className="text-[#c5b4e3] text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Privacy Policy
|
||||
</a>
|
||||
</nav>
|
||||
<p className="text-[#c5b4e3] text-base font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
© 2025 LOAF. All Rights Reserved.
|
||||
</p>
|
||||
<p className="text-[#c5b4e3] text-base font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Designed and Managed by{' '}
|
||||
<a href="https://konceptkit.com/" className="text-[#d1c3e9] underline hover:text-white transition-colors">
|
||||
Koncept Kit
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ import { PasswordInput } from '../components/ui/password-input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import Navbar from '../components/Navbar';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const Login = () => {
|
||||
@@ -55,7 +56,7 @@ const Login = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
@@ -129,6 +130,8 @@ const Login = () => {
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import Navbar from '../components/Navbar';
|
||||
import MemberFooter from '../components/MemberFooter';
|
||||
import { User, Save, Lock } from 'lucide-react';
|
||||
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
||||
|
||||
@@ -242,6 +243,7 @@ const Profile = () => {
|
||||
onOpenChange={setPasswordDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import Navbar from '../components/Navbar';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
import RegistrationStepIndicator from '../components/registration/RegistrationStepIndicator';
|
||||
import RegistrationStep1 from '../components/registration/RegistrationStep1';
|
||||
@@ -183,7 +184,7 @@ const Register = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
@@ -284,6 +285,8 @@ const Register = () => {
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import Navbar from '../components/Navbar';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
import { ArrowRight, Lock, AlertCircle } from 'lucide-react';
|
||||
|
||||
const ResetPassword = () => {
|
||||
@@ -64,7 +65,7 @@ const ResetPassword = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
@@ -140,6 +141,8 @@ const ResetPassword = () => {
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ import axios from 'axios';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import Navbar from '../components/Navbar';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
|
||||
const API_URL = process.env.REACT_APP_BACKEND_URL;
|
||||
|
||||
@@ -38,7 +39,7 @@ const VerifyEmail = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-2xl mx-auto px-6 py-20">
|
||||
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg text-center">
|
||||
@@ -98,6 +99,8 @@ const VerifyEmail = () => {
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
477
src/pages/admin/AdminBylaws.js
Normal file
477
src/pages/admin/AdminBylaws.js
Normal file
@@ -0,0 +1,477 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Scale,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminBylaws = () => {
|
||||
const [bylaws, setBylaws] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedBylaws, setSelectedBylaws] = useState(null);
|
||||
const [bylawsToDelete, setBylawsToDelete] = useState(null);
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
version: '',
|
||||
effective_date: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive',
|
||||
is_current: false
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBylaws();
|
||||
}, []);
|
||||
|
||||
const fetchBylaws = async () => {
|
||||
try {
|
||||
const response = await api.get('/bylaws/history');
|
||||
setBylaws(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch bylaws');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedBylaws(null);
|
||||
setFormData({
|
||||
title: 'LOAF Bylaws',
|
||||
version: '',
|
||||
effective_date: new Date().toISOString().split('T')[0],
|
||||
document_url: '',
|
||||
document_type: 'google_drive',
|
||||
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (bylawsDoc) => {
|
||||
setSelectedBylaws(bylawsDoc);
|
||||
setFormData({
|
||||
title: bylawsDoc.title,
|
||||
version: bylawsDoc.version,
|
||||
effective_date: new Date(bylawsDoc.effective_date).toISOString().split('T')[0],
|
||||
document_url: bylawsDoc.document_url,
|
||||
document_type: bylawsDoc.document_type,
|
||||
is_current: bylawsDoc.is_current
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (bylawsDoc) => {
|
||||
setBylawsToDelete(bylawsDoc);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('title', formData.title);
|
||||
formDataToSend.append('version', formData.version);
|
||||
formDataToSend.append('effective_date', new Date(formData.effective_date).toISOString());
|
||||
formDataToSend.append('document_type', formData.document_type);
|
||||
formDataToSend.append('is_current', formData.is_current);
|
||||
|
||||
// Handle file upload or URL based on document_type
|
||||
if (formData.document_type === 'upload') {
|
||||
if (!uploadedFile && !selectedBylaws) {
|
||||
toast.error('Please select a file to upload');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (uploadedFile) {
|
||||
formDataToSend.append('file', uploadedFile);
|
||||
}
|
||||
} else {
|
||||
formDataToSend.append('document_url', formData.document_url);
|
||||
}
|
||||
|
||||
if (selectedBylaws) {
|
||||
await api.put(`/admin/bylaws/${selectedBylaws.id}`, formDataToSend);
|
||||
toast.success('Bylaws updated successfully');
|
||||
} else {
|
||||
await api.post('/admin/bylaws', formDataToSend);
|
||||
toast.success('Bylaws created successfully');
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
setUploadedFile(null);
|
||||
fetchBylaws();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to save bylaws');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/admin/bylaws/${bylawsToDelete.id}`);
|
||||
toast.success('Bylaws deleted successfully');
|
||||
setDeleteDialogOpen(false);
|
||||
fetchBylaws();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete bylaws');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const currentBylaws = bylaws.find(b => b.is_current);
|
||||
const historicalBylaws = bylaws.filter(b => !b.is_current).sort((a, b) =>
|
||||
new Date(b.effective_date) - new Date(a.effective_date)
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]">Loading bylaws...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Bylaws Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage LOAF governing bylaws and version history
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Version
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Current Bylaws */}
|
||||
{currentBylaws ? (
|
||||
<Card className="p-6 border-2 border-[#664fa3]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-3 rounded-xl">
|
||||
<Scale className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-[#422268]">
|
||||
{currentBylaws.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className="bg-[#81B29A] text-white">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Current Version
|
||||
</Badge>
|
||||
<span className="text-[#664fa3] text-sm">
|
||||
Version {currentBylaws.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(currentBylaws.document_url, '_blank')}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(currentBylaws)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(currentBylaws)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
||||
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
|
||||
<span>•</span>
|
||||
<span>Document Type: <strong>{currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()}</strong></span>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-12 text-center">
|
||||
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Bylaws
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Historical Versions */}
|
||||
{historicalBylaws.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[#422268] mb-4">
|
||||
Version History ({historicalBylaws.length})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{historicalBylaws.map(bylawsDoc => (
|
||||
<Card key={bylawsDoc.id} className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-1">
|
||||
{bylawsDoc.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-sm text-[#664fa3]">
|
||||
<span>Version {bylawsDoc.version}</span>
|
||||
<span>•</span>
|
||||
<span>Effective {formatDate(bylawsDoc.effective_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(bylawsDoc.document_url, '_blank')}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(bylawsDoc)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(bylawsDoc)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedBylaws ? 'Edit Bylaws' : 'Add Bylaws Version'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedBylaws ? 'Update bylaws information' : 'Add a new version of the bylaws'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="LOAF Bylaws"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="version">Version *</Label>
|
||||
<Input
|
||||
id="version"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
placeholder="v1.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="effective_date">Effective Date *</Label>
|
||||
<Input
|
||||
id="effective_date"
|
||||
type="date"
|
||||
value={formData.effective_date}
|
||||
onChange={(e) => setFormData({ ...formData, effective_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.document_type === 'upload' ? (
|
||||
<div>
|
||||
<Label htmlFor="document_file">Upload PDF File *</Label>
|
||||
<Input
|
||||
id="document_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setUploadedFile(e.target.files[0])}
|
||||
required={!selectedBylaws}
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="document_url">Document URL *</Label>
|
||||
<Input
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||
placeholder="https://drive.google.com/file/d/..."
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="is_current"
|
||||
checked={formData.is_current}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_current: checked })}
|
||||
/>
|
||||
<label
|
||||
htmlFor="is_current"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Set as current version (will unset all other versions)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#664fa3] text-white"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : selectedBylaws ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Bylaws</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{bylawsToDelete?.title} ({bylawsToDelete?.version})"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBylaws;
|
||||
374
src/pages/admin/AdminFinancials.js
Normal file
374
src/pages/admin/AdminFinancials.js
Normal file
@@ -0,0 +1,374 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
DollarSign,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminFinancials = () => {
|
||||
const [reports, setReports] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedReport, setSelectedReport] = useState(null);
|
||||
const [reportToDelete, setReportToDelete] = useState(null);
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
year: new Date().getFullYear(),
|
||||
title: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive'
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
}, []);
|
||||
|
||||
const fetchReports = async () => {
|
||||
try {
|
||||
const response = await api.get('/financials');
|
||||
setReports(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch financial reports');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedReport(null);
|
||||
setFormData({
|
||||
year: new Date().getFullYear(),
|
||||
title: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive'
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (report) => {
|
||||
setSelectedReport(report);
|
||||
setFormData({
|
||||
year: report.year,
|
||||
title: report.title,
|
||||
document_url: report.document_url,
|
||||
document_type: report.document_type
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (report) => {
|
||||
setReportToDelete(report);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('year', formData.year);
|
||||
formDataToSend.append('title', formData.title);
|
||||
formDataToSend.append('document_type', formData.document_type);
|
||||
|
||||
// Handle file upload or URL based on document_type
|
||||
if (formData.document_type === 'upload') {
|
||||
if (!uploadedFile && !selectedReport) {
|
||||
toast.error('Please select a file to upload');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (uploadedFile) {
|
||||
formDataToSend.append('file', uploadedFile);
|
||||
}
|
||||
} else {
|
||||
formDataToSend.append('document_url', formData.document_url);
|
||||
}
|
||||
|
||||
if (selectedReport) {
|
||||
await api.put(`/admin/financials/${selectedReport.id}`, formDataToSend);
|
||||
toast.success('Financial report updated successfully');
|
||||
} else {
|
||||
await api.post('/admin/financials', formDataToSend);
|
||||
toast.success('Financial report created successfully');
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
setUploadedFile(null);
|
||||
fetchReports();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to save financial report');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/admin/financials/${reportToDelete.id}`);
|
||||
toast.success('Financial report deleted successfully');
|
||||
setDeleteDialogOpen(false);
|
||||
fetchReports();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete financial report');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]">Loading financial reports...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Financial Reports Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage annual financial reports
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Report
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reports List */}
|
||||
{reports.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Report
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reports.map(report => (
|
||||
<Card key={report.id} className="p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-4 rounded-xl text-white min-w-[100px] text-center">
|
||||
<DollarSign className="h-6 w-6 mx-auto mb-1" />
|
||||
<div className="text-2xl font-bold">{report.year}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">
|
||||
{report.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
||||
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(report.document_url, '_blank')}
|
||||
className="text-[#664fa3] hover:text-[#533a82]"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(report)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(report)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedReport ? 'Edit Financial Report' : 'Add Financial Report'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedReport ? 'Update financial report information' : 'Add a new financial report'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="year">Fiscal Year *</Label>
|
||||
<Input
|
||||
id="year"
|
||||
type="number"
|
||||
value={formData.year}
|
||||
onChange={(e) => setFormData({ ...formData, year: parseInt(e.target.value) })}
|
||||
placeholder="2024"
|
||||
required
|
||||
min="2000"
|
||||
max="2100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="2024 Annual Financial Report"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.document_type === 'upload' ? (
|
||||
<div>
|
||||
<Label htmlFor="document_file">Upload PDF File *</Label>
|
||||
<Input
|
||||
id="document_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setUploadedFile(e.target.files[0])}
|
||||
required={!selectedReport}
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="document_url">Document URL *</Label>
|
||||
<Input
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||
placeholder="https://drive.google.com/file/d/..."
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#664fa3] text-white"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : selectedReport ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Financial Report</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{reportToDelete?.title}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminFinancials;
|
||||
354
src/pages/admin/AdminGallery.js
Normal file
354
src/pages/admin/AdminGallery.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import moment from 'moment';
|
||||
|
||||
const AdminGallery = () => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [galleryImages, setGalleryImages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editingCaption, setEditingCaption] = useState(null);
|
||||
const [newCaption, setNewCaption] = useState('');
|
||||
const [maxFileSize, setMaxFileSize] = useState(5242880); // Default 5MB
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
fetchConfigLimits();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEvent) {
|
||||
fetchGallery(selectedEvent);
|
||||
}
|
||||
}, [selectedEvent]);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/events');
|
||||
setEvents(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch events:', error);
|
||||
toast.error('Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGallery = async (eventId) => {
|
||||
try {
|
||||
const response = await api.get(`/events/${eventId}/gallery`);
|
||||
setGalleryImages(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch gallery:', error);
|
||||
toast.error('Failed to load gallery');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfigLimits = async () => {
|
||||
try {
|
||||
const response = await api.get('/config/limits');
|
||||
setMaxFileSize(response.data.max_file_size_bytes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config limits:', error);
|
||||
// Keep default value (5MB) if fetch fails
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
else return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
await api.post(`/admin/events/${selectedEvent}/gallery`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(`${files.length} ${files.length === 1 ? 'image' : 'images'} uploaded successfully`);
|
||||
await fetchGallery(selectedEvent);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload images:', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to upload images');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImage = async (imageId) => {
|
||||
if (!window.confirm('Are you sure you want to delete this image?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/admin/event-gallery/${imageId}`);
|
||||
toast.success('Image deleted successfully');
|
||||
await fetchGallery(selectedEvent);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete image:', error);
|
||||
toast.error('Failed to delete image');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCaption = async () => {
|
||||
if (!editingCaption) return;
|
||||
|
||||
try {
|
||||
await api.put(`/admin/event-gallery/${editingCaption.id}`, null, {
|
||||
params: { caption: newCaption }
|
||||
});
|
||||
toast.success('Caption updated successfully');
|
||||
setEditingCaption(null);
|
||||
setNewCaption('');
|
||||
await fetchGallery(selectedEvent);
|
||||
} catch (error) {
|
||||
console.error('Failed to update caption:', error);
|
||||
toast.error('Failed to update caption');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditCaption = (image) => {
|
||||
setEditingCaption(image);
|
||||
setNewCaption(image.caption || '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Event Gallery Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Upload and manage photos for event galleries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Event Selection */}
|
||||
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Select Event
|
||||
</Label>
|
||||
<Select value={selectedEvent || ''} onValueChange={setSelectedEvent}>
|
||||
<SelectTrigger className="border-[#ddd8eb] rounded-xl">
|
||||
<SelectValue placeholder="Choose an event..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{events.map((event) => (
|
||||
<SelectItem key={event.id} value={event.id}>
|
||||
{event.title} - {moment(event.start_at).format('MMM D, YYYY')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedEvent && (
|
||||
<div className="pt-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload Images
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You can select multiple images. Max {formatFileSize(maxFileSize)} per image.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Gallery Grid */}
|
||||
{selectedEvent && (
|
||||
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Gallery Images
|
||||
</h2>
|
||||
<Badge className="bg-[#664fa3] text-white px-3 py-1">
|
||||
{galleryImages.length} {galleryImages.length === 1 ? 'image' : 'images'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{galleryImages.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{galleryImages.map((image) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-[#F8F7FB]">
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt={image.caption || 'Gallery image'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Overlay with Actions */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => openEditCaption(image)}
|
||||
size="sm"
|
||||
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Caption
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteImage(image.id)}
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Caption Preview */}
|
||||
{image.caption && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-[#664fa3] line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{image.caption}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Size */}
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{formatFileSize(image.file_size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<ImageIcon className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Images Yet
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Upload images to create a gallery for this event.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Edit Caption Dialog */}
|
||||
<Dialog open={!!editingCaption} onOpenChange={() => setEditingCaption(null)}>
|
||||
<DialogContent className="bg-white sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Edit Image Caption
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{editingCaption && (
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-video rounded-xl overflow-hidden bg-[#F8F7FB]">
|
||||
<img
|
||||
src={editingCaption.image_url}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Caption
|
||||
</Label>
|
||||
<Textarea
|
||||
value={newCaption}
|
||||
onChange={(e) => setNewCaption(e.target.value)}
|
||||
placeholder="Add a caption for this image..."
|
||||
rows={3}
|
||||
className="border-[#ddd8eb] rounded-xl mt-2"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setEditingCaption(null)}
|
||||
variant="outline"
|
||||
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateCaption}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Save Caption
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminGallery;
|
||||
422
src/pages/admin/AdminNewsletters.js
Normal file
422
src/pages/admin/AdminNewsletters.js
Normal file
@@ -0,0 +1,422 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Calendar,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminNewsletters = () => {
|
||||
const [newsletters, setNewsletters] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedNewsletter, setSelectedNewsletter] = useState(null);
|
||||
const [newsletterToDelete, setNewsletterToDelete] = useState(null);
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
published_date: '',
|
||||
document_url: '',
|
||||
document_type: 'google_docs'
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNewsletters();
|
||||
}, []);
|
||||
|
||||
const fetchNewsletters = async () => {
|
||||
try {
|
||||
const response = await api.get('/newsletters');
|
||||
setNewsletters(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch newsletters');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedNewsletter(null);
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
document_url: '',
|
||||
document_type: 'google_docs'
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (newsletter) => {
|
||||
setSelectedNewsletter(newsletter);
|
||||
setFormData({
|
||||
title: newsletter.title,
|
||||
description: newsletter.description || '',
|
||||
published_date: new Date(newsletter.published_date).toISOString().split('T')[0],
|
||||
document_url: newsletter.document_url,
|
||||
document_type: newsletter.document_type
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (newsletter) => {
|
||||
setNewsletterToDelete(newsletter);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('title', formData.title);
|
||||
formDataToSend.append('description', formData.description);
|
||||
formDataToSend.append('published_date', new Date(formData.published_date).toISOString());
|
||||
formDataToSend.append('document_type', formData.document_type);
|
||||
|
||||
// Handle file upload or URL based on document_type
|
||||
if (formData.document_type === 'upload') {
|
||||
if (!uploadedFile && !selectedNewsletter) {
|
||||
toast.error('Please select a file to upload');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (uploadedFile) {
|
||||
formDataToSend.append('file', uploadedFile);
|
||||
}
|
||||
} else {
|
||||
formDataToSend.append('document_url', formData.document_url);
|
||||
}
|
||||
|
||||
if (selectedNewsletter) {
|
||||
await api.put(`/admin/newsletters/${selectedNewsletter.id}`, formDataToSend);
|
||||
toast.success('Newsletter updated successfully');
|
||||
} else {
|
||||
await api.post('/admin/newsletters', formDataToSend);
|
||||
toast.success('Newsletter created successfully');
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
setUploadedFile(null);
|
||||
fetchNewsletters();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to save newsletter');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/admin/newsletters/${newsletterToDelete.id}`);
|
||||
toast.success('Newsletter deleted successfully');
|
||||
setDeleteDialogOpen(false);
|
||||
fetchNewsletters();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete newsletter');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const groupByYear = (newsletters) => {
|
||||
const grouped = {};
|
||||
newsletters.forEach(newsletter => {
|
||||
const year = new Date(newsletter.published_date).getFullYear();
|
||||
if (!grouped[year]) {
|
||||
grouped[year] = [];
|
||||
}
|
||||
grouped[year].push(newsletter);
|
||||
});
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const groupedNewsletters = groupByYear(newsletters);
|
||||
const sortedYears = Object.keys(groupedNewsletters).sort((a, b) => b - a);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]">Loading newsletters...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Newsletter Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Create and manage newsletter archive
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Newsletter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Newsletters List */}
|
||||
{newsletters.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Newsletter
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{sortedYears.map(year => (
|
||||
<div key={year}>
|
||||
<h2 className="text-xl font-semibold text-[#422268] mb-4 flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
{year}
|
||||
</h2>
|
||||
<div className="grid gap-4">
|
||||
{groupedNewsletters[year].map(newsletter => (
|
||||
<Card key={newsletter.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">
|
||||
{newsletter.title}
|
||||
</h3>
|
||||
{newsletter.description && (
|
||||
<p className="text-[#664fa3] mb-3">{newsletter.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-[#DDD8EB] text-[#422268]">
|
||||
{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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(newsletter.document_url, '_blank')}
|
||||
className="text-[#664fa3] hover:text-[#533a82]"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(newsletter)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(newsletter)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedNewsletter ? 'Edit Newsletter' : 'Add Newsletter'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedNewsletter ? 'Update newsletter information' : 'Add a new newsletter to the archive'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="January 2025 Newsletter"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Monthly community updates and announcements"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="published_date">Published Date *</Label>
|
||||
<Input
|
||||
id="published_date"
|
||||
type="date"
|
||||
value={formData.published_date}
|
||||
onChange={(e) => setFormData({ ...formData, published_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_docs">Google Docs</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.document_type === 'upload' ? (
|
||||
<div>
|
||||
<Label htmlFor="document_file">Upload PDF File *</Label>
|
||||
<Input
|
||||
id="document_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setUploadedFile(e.target.files[0])}
|
||||
required={!selectedNewsletter}
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="document_url">Document URL *</Label>
|
||||
<Input
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||
placeholder="https://docs.google.com/document/d/..."
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_docs' && 'Paste the shareable link to your Google Doc'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#664fa3] text-white"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : selectedNewsletter ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Newsletter</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{newsletterToDelete?.title}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminNewsletters;
|
||||
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