Initial Commit

This commit is contained in:
Koncept Kit
2025-12-05 16:40:33 +07:00
parent 0834f12410
commit 94c7d5aec0
91 changed files with 20446 additions and 0 deletions

230
src/pages/Dashboard.js Normal file
View File

@@ -0,0 +1,230 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
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 } from 'lucide-react';
const Dashboard = () => {
const { user } = useAuth();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUpcomingEvents();
}, []);
const fetchUpcomingEvents = async () => {
try {
const response = await api.get('/events');
const upcomingEvents = response.data.slice(0, 3);
setEvents(upcomingEvents);
} catch (error) {
console.error('Failed to fetch events:', error);
} finally {
setLoading(false);
}
};
const getStatusBadge = (status) => {
const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-[#F2CC8F] text-[#3D405B]' },
pending_approval: { icon: Clock, label: 'Pending Approval', className: 'bg-[#A3B1C6] text-white' },
pre_approved: { icon: CheckCircle, label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
payment_pending: { icon: AlertCircle, label: 'Payment Pending', className: 'bg-[#E07A5F] text-white' },
active: { icon: CheckCircle, label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { icon: AlertCircle, label: 'Inactive', className: 'bg-[#6B708D] text-white' }
};
const config = statusConfig[status] || statusConfig.inactive;
const Icon = config.icon;
return (
<Badge className={`${config.className} px-4 py-2 rounded-full flex items-center gap-2`}>
<Icon className="h-4 w-4" />
{config.label}
</Badge>
);
};
const getStatusMessage = (status) => {
const messages = {
pending_email: 'Please check your email to verify your account.',
pending_approval: 'Your application is under review by our admin team.',
pre_approved: 'Your application is under review by our admin team.',
payment_pending: 'Please complete your payment to activate your membership.',
active: 'Your membership is active! Enjoy all member benefits.',
inactive: 'Your membership is currently inactive.'
};
return messages[status] || '';
};
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Welcome Section */}
<div className="mb-12">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Welcome Back, {user?.first_name}!
</h1>
<p className="text-lg text-[#6B708D]">
Here's an overview of your membership status and upcoming events.
</p>
</div>
{/* Status Card */}
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] shadow-lg mb-8" data-testid="status-card">
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-2">
Membership Status
</h2>
<div className="mb-4">
{getStatusBadge(user?.status)}
</div>
<p className="text-[#6B708D]">
{getStatusMessage(user?.status)}
</p>
</div>
<Link to="/profile">
<Button
className="bg-[#F2CC8F] text-[#3D405B] hover:bg-[#E5B875] rounded-full px-6"
data-testid="view-profile-button"
>
<User className="h-4 w-4 mr-2" />
View Profile
</Button>
</Link>
</div>
</Card>
{/* Grid Layout */}
<div className="grid lg:grid-cols-3 gap-8">
{/* Quick Stats */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]" data-testid="quick-stats-card">
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-4">
Quick Info
</h3>
<div className="space-y-4">
<div>
<p className="text-sm text-[#6B708D]">Email</p>
<p className="text-[#3D405B] font-medium">{user?.email}</p>
</div>
<div>
<p className="text-sm text-[#6B708D]">Role</p>
<p className="text-[#3D405B] font-medium capitalize">{user?.role}</p>
</div>
<div>
<p className="text-sm text-[#6B708D]">Member Since</p>
<p className="text-[#3D405B] font-medium">
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
</p>
</div>
</div>
</Card>
{/* Upcoming Events */}
<Card className="lg:col-span-2 p-6 bg-white rounded-2xl border border-[#EAE0D5]" data-testid="upcoming-events-card">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold fraunces text-[#3D405B]">
Upcoming Events
</h3>
<Link to="/events">
<Button
variant="ghost"
className="text-[#E07A5F] hover:text-[#D0694E]"
data-testid="view-all-events-button"
>
View All
</Button>
</Link>
</div>
{loading ? (
<p className="text-[#6B708D]">Loading events...</p>
) : events.length > 0 ? (
<div className="space-y-4">
{events.map((event) => (
<Link to={`/events/${event.id}`} key={event.id}>
<div
className="p-4 border border-[#EAE0D5] rounded-xl hover:border-[#E07A5F] hover:shadow-md transition-all cursor-pointer"
data-testid={`event-card-${event.id}`}
>
<div className="flex items-start gap-4">
<div className="bg-[#F2CC8F]/20 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-[#E07A5F]" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-[#3D405B] mb-1">{event.title}</h4>
<p className="text-sm text-[#6B708D] mb-2">
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
<p className="text-sm text-[#6B708D]">{event.location}</p>
</div>
</div>
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-12">
<Calendar className="h-16 w-16 text-[#EAE0D5] mx-auto mb-4" />
<p className="text-[#6B708D] mb-4">No upcoming events at the moment.</p>
<p className="text-sm text-[#6B708D]">Check back later for new events!</p>
</div>
)}
</Card>
</div>
{/* CTA Section */}
{user?.status === 'pending_approval' && (
<Card className="mt-8 p-8 bg-gradient-to-br from-[#F2CC8F]/20 to-[#E07A5F]/20 rounded-2xl border border-[#EAE0D5]">
<div className="text-center">
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
Application Under Review
</h3>
<p className="text-[#6B708D] mb-6">
Your membership application is being reviewed by our admin team. You'll be notified once approved!
</p>
</div>
</Card>
)}
{/* Payment Prompt for payment_pending status */}
{user?.status === 'payment_pending' && (
<Card className="mt-8 p-8 bg-gradient-to-br from-[#E07A5F]/20 to-[#F2CC8F]/20 rounded-2xl border-2 border-[#E07A5F]">
<div className="text-center">
<div className="mb-4">
<AlertCircle className="h-16 w-16 text-[#E07A5F] mx-auto" />
</div>
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
Complete Your Payment
</h3>
<p className="text-[#6B708D] mb-6">
Great news! Your membership application has been approved. Complete your payment to activate your membership and gain full access to all member benefits.
</p>
<Link to="/plans">
<Button
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-8 py-6 text-lg font-semibold"
data-testid="complete-payment-cta"
>
<CheckCircle className="mr-2 h-5 w-5" />
Complete Payment
</Button>
</Link>
</div>
</Card>
)}
</div>
</div>
);
};
export default Dashboard;

201
src/pages/EventDetails.js Normal file
View File

@@ -0,0 +1,201 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../utils/api';
import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import { Calendar, MapPin, Users, ArrowLeft, Check, X, HelpCircle } from 'lucide-react';
const EventDetails = () => {
const { id } = useParams();
const navigate = useNavigate();
const [event, setEvent] = useState(null);
const [loading, setLoading] = useState(true);
const [rsvpLoading, setRsvpLoading] = useState(false);
useEffect(() => {
fetchEvent();
}, [id]);
const fetchEvent = async () => {
try {
const response = await api.get(`/events/${id}`);
setEvent(response.data);
} catch (error) {
toast.error('Failed to load event');
navigate('/events');
} finally {
setLoading(false);
}
};
const handleRSVP = async (status) => {
setRsvpLoading(true);
try {
await api.post(`/events/${id}/rsvp`, { rsvp_status: status });
toast.success(`RSVP updated to: ${status}`);
fetchEvent();
} catch (error) {
toast.error('Failed to update RSVP');
} finally {
setRsvpLoading(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#6B708D]">Loading event...</p>
</div>
</div>
);
}
if (!event) {
return null;
}
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<button
onClick={() => navigate('/events')}
className="inline-flex items-center text-[#6B708D] hover:text-[#E07A5F] transition-colors mb-8"
data-testid="back-to-events-button"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Events
</button>
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#EAE0D5] shadow-lg">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<div className="bg-[#F2CC8F]/20 p-4 rounded-xl">
<Calendar className="h-10 w-10 text-[#E07A5F]" />
</div>
{event.user_rsvp_status && (
<Badge
className={`px-4 py-2 rounded-full text-sm ${
event.user_rsvp_status === 'yes'
? 'bg-[#81B29A] text-white'
: event.user_rsvp_status === 'no'
? 'bg-[#6B708D] text-white'
: 'bg-[#F2CC8F] text-[#3D405B]'
}`}
>
{event.user_rsvp_status === 'yes' && 'Going'}
{event.user_rsvp_status === 'no' && 'Not Going'}
{event.user_rsvp_status === 'maybe' && 'Maybe'}
</Badge>
)}
</div>
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-6">
{event.title}
</h1>
<div className="space-y-4 text-lg">
<div className="flex items-center gap-3 text-[#6B708D]">
<Calendar className="h-5 w-5" />
<span>
{new Date(event.start_at).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
</div>
<div className="flex items-center gap-3 text-[#6B708D]">
<Calendar className="h-5 w-5" />
<span>
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -{' '}
{new Date(event.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex items-center gap-3 text-[#6B708D]">
<MapPin className="h-5 w-5" />
<span>{event.location}</span>
</div>
<div className="flex items-center gap-3 text-[#6B708D]">
<Users className="h-5 w-5" />
<span>
{event.rsvp_count || 0} {event.rsvp_count === 1 ? 'person' : 'people'} attending
{event.capacity && ` (Capacity: ${event.capacity})`}
</span>
</div>
</div>
</div>
{event.description && (
<div className="mb-8 pb-8 border-b border-[#EAE0D5]">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
About This Event
</h2>
<p className="text-[#6B708D] leading-relaxed whitespace-pre-line">
{event.description}
</p>
</div>
)}
<div>
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6">
RSVP to This Event
</h2>
<div className="flex gap-4 flex-wrap">
<Button
onClick={() => handleRSVP('yes')}
disabled={rsvpLoading}
className={`rounded-full px-8 py-6 flex items-center gap-2 ${
event.user_rsvp_status === 'yes'
? 'bg-[#81B29A] text-white'
: 'bg-[#E07A5F] text-white hover:bg-[#D0694E]'
}`}
data-testid="rsvp-yes-button"
>
<Check className="h-5 w-5" />
I'm Going
</Button>
<Button
onClick={() => handleRSVP('maybe')}
disabled={rsvpLoading}
variant="outline"
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${
event.user_rsvp_status === 'maybe'
? 'border-[#F2CC8F] bg-[#F2CC8F]/20 text-[#3D405B]'
: 'border-[#3D405B] text-[#3D405B] hover:bg-[#F2CC8F]/10'
}`}
data-testid="rsvp-maybe-button"
>
<HelpCircle className="h-5 w-5" />
Maybe
</Button>
<Button
onClick={() => handleRSVP('no')}
disabled={rsvpLoading}
variant="outline"
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${
event.user_rsvp_status === 'no'
? 'border-[#6B708D] bg-[#6B708D]/20 text-[#3D405B]'
: 'border-[#6B708D] text-[#6B708D] hover:bg-[#6B708D]/10'
}`}
data-testid="rsvp-no-button"
>
<X className="h-5 w-5" />
Can't Attend
</Button>
</div>
</div>
</Card>
</div>
</div>
);
};
export default EventDetails;

132
src/pages/Events.js Normal file
View File

@@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../utils/api';
import { Card } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar';
import { Calendar, MapPin, Users, ArrowRight } from 'lucide-react';
const Events = () => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
const response = await api.get('/events');
setEvents(response.data);
} catch (error) {
console.error('Failed to fetch events:', error);
} finally {
setLoading(false);
}
};
const getRSVPBadge = (rsvpStatus) => {
if (!rsvpStatus) return null;
const config = {
yes: { label: 'Going', className: 'bg-[#81B29A] text-white' },
no: { label: 'Not Going', className: 'bg-[#6B708D] text-white' },
maybe: { label: 'Maybe', className: 'bg-[#F2CC8F] text-[#3D405B]' }
};
const rsvpConfig = config[rsvpStatus];
if (!rsvpConfig) return null;
return (
<Badge className={`${rsvpConfig.className} px-3 py-1 rounded-full text-sm`}>
{rsvpConfig.label}
</Badge>
);
};
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="mb-12">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Upcoming Events
</h1>
<p className="text-lg text-[#6B708D]">
Browse and RSVP to our community events.
</p>
</div>
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading events...</p>
</div>
) : events.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{events.map((event) => (
<Link to={`/events/${event.id}`} key={event.id}>
<Card
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer h-full"
data-testid={`event-card-${event.id}`}
>
<div className="flex justify-between items-start mb-4">
<div className="bg-[#F2CC8F]/20 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-[#E07A5F]" />
</div>
{getRSVPBadge(event.user_rsvp_status)}
</div>
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-3">
{event.title}
</h3>
{event.description && (
<p className="text-[#6B708D] mb-4 line-clamp-2">
{event.description}
</p>
)}
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-[#6B708D]">
<Calendar className="h-4 w-4" />
<span>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex items-center gap-2 text-[#6B708D]">
<MapPin className="h-4 w-4" />
<span>{event.location}</span>
</div>
<div className="flex items-center gap-2 text-[#6B708D]">
<Users className="h-4 w-4" />
<span>{event.rsvp_count || 0} attending</span>
</div>
</div>
<div className="mt-6 flex items-center text-[#E07A5F] font-medium">
View Details
<ArrowRight className="ml-2 h-4 w-4" />
</div>
</Card>
</Link>
))}
</div>
) : (
<div className="text-center py-20">
<Calendar className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Events Available
</h3>
<p className="text-[#6B708D]">
There are no upcoming events at the moment. Check back later!
</p>
</div>
)}
</div>
</div>
);
};
export default Events;

140
src/pages/Landing.js Normal file
View File

@@ -0,0 +1,140 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { Calendar, Users, Heart, ArrowRight } from 'lucide-react';
import Navbar from '../components/Navbar';
const Landing = () => {
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
{/* Hero Section */}
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-soft-mesh"></div>
<div className="max-w-7xl mx-auto px-6 py-20 lg:py-32 relative">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div className="space-y-8">
<h1 className="text-5xl md:text-7xl font-semibold fraunces text-[#3D405B] leading-tight">
Building Friendships, One Event at a Time
</h1>
<p className="text-lg md:text-xl text-[#6B708D] leading-relaxed">
Join our vibrant community of members connecting through shared experiences, events, and meaningful relationships.
</p>
<div className="flex gap-4 flex-wrap">
<Link to="/register">
<Button
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-8 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform"
data-testid="hero-join-button"
>
Become a Member
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link to="/login">
<Button
variant="outline"
className="border-2 border-[#3D405B] text-[#3D405B] hover:bg-[#3D405B] hover:text-white rounded-full px-8 py-6 text-lg font-medium transition-all"
data-testid="hero-login-button"
>
Member Login
</Button>
</Link>
</div>
</div>
<div className="relative">
<img
src="https://images.unsplash.com/photo-1660405251862-c023df45d075?crop=entropy&cs=srgb&fm=jpg&ixid=M3w3NDk1ODF8MHwxfHNlYXJjaHwxfHxhY3RpdmUlMjBzZW5pb3IlMjB3b21lbiUyMGdyb3VwJTIwaGlraW5nJTIwbmF0dXJlfGVufDB8fHx8MTc2NDc1NjIwM3ww&ixlib=rb-4.1.0&q=85"
alt="Community members enjoying outdoor activities"
className="rounded-2xl shadow-2xl w-full h-[500px] object-cover"
/>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="max-w-7xl mx-auto px-6 py-20">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
What We Offer
</h2>
<p className="text-lg text-[#6B708D] max-w-2xl mx-auto">
No matter your age or ability, there is something for everyone in our community.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg transition-shadow" data-testid="feature-community-card">
<div className="bg-[#F2CC8F]/20 w-16 h-16 rounded-full flex items-center justify-center mb-6">
<Users className="h-8 w-8 text-[#E07A5F]" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
Meet & Greet
</h3>
<p className="text-[#6B708D] leading-relaxed">
Connect with prospective members and get acquainted with our community. Meet the board and ask questions in a welcoming environment.
</p>
</Card>
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg transition-shadow" data-testid="feature-events-card">
<div className="bg-[#F2CC8F]/20 w-16 h-16 rounded-full flex items-center justify-center mb-6">
<Calendar className="h-8 w-8 text-[#E07A5F]" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
Social Events
</h3>
<p className="text-[#6B708D] leading-relaxed">
Explore your city with fellow members. From museums to sporting events, board games to pool parties - there's always something happening.
</p>
</Card>
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg transition-shadow" data-testid="feature-activities-card">
<div className="bg-[#F2CC8F]/20 w-16 h-16 rounded-full flex items-center justify-center mb-6">
<Heart className="h-8 w-8 text-[#E07A5F]" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
Active Living
</h3>
<p className="text-[#6B708D] leading-relaxed">
Stay active with hiking, swimming, pickleball, kayaking, and more. Activities designed for all abilities and fitness levels.
</p>
</Card>
</div>
</section>
{/* CTA Section */}
<section className="bg-gradient-to-br from-[#F2CC8F]/20 to-[#E07A5F]/20 py-20">
<div className="max-w-4xl mx-auto px-6 text-center">
<h2 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-6">
Ready to Join Our Community?
</h2>
<p className="text-lg text-[#6B708D] mb-8">
Start your membership journey today and connect with amazing people in your area.
</p>
<Link to="/register">
<Button
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-12 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform"
data-testid="cta-register-button"
>
Get Started Now
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
</div>
</section>
{/* Footer */}
<footer className="bg-[#3D405B] text-white py-12">
<div className="max-w-7xl mx-auto px-6 text-center">
<p className="text-[#A3B1C6]">
© 2025 Membership Platform. Building connections, creating community.
</p>
</div>
</footer>
</div>
);
};
export default Landing;

122
src/pages/Login.js Normal file
View File

@@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Button } from '../components/ui/button';
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 { ArrowRight, ArrowLeft } from 'lucide-react';
const Login = () => {
const navigate = useNavigate();
const { login } = useAuth();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: ''
});
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const user = await login(formData.email, formData.password);
toast.success('Login successful!');
if (user.role === 'admin') {
navigate('/admin');
} else {
navigate('/dashboard');
}
} catch (error) {
toast.error(error.response?.data?.detail || 'Login failed. Please check your credentials.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-md mx-auto px-6 py-12">
<div className="mb-8">
<Link to="/" className="inline-flex items-center text-[#6B708D] hover:text-[#E07A5F] transition-colors">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Home
</Link>
</div>
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#EAE0D5] shadow-lg">
<div className="mb-8 text-center">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Welcome Back
</h1>
<p className="text-lg text-[#6B708D]">
Login to access your member dashboard.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6" data-testid="login-form">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleInputChange}
placeholder="your.email@example.com"
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="login-email-input"
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={handleInputChange}
placeholder="Enter your password"
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="login-password-input"
/>
</div>
<Button
type="submit"
disabled={loading}
className="w-full bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
data-testid="login-submit-button"
>
{loading ? 'Logging in...' : 'Login'}
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
<p className="text-center text-[#6B708D]">
Don't have an account?{' '}
<Link to="/register" className="text-[#E07A5F] hover:underline font-medium">
Register here
</Link>
</p>
</form>
</Card>
</div>
</div>
);
};
export default Login;

116
src/pages/PaymentCancel.js Normal file
View File

@@ -0,0 +1,116 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button';
import Navbar from '../components/Navbar';
import { XCircle, ArrowLeft, CreditCard, Mail } from 'lucide-react';
const PaymentCancel = () => {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="text-center mb-12">
{/* Cancel Icon */}
<div className="mb-8">
<div className="bg-[#6B708D] rounded-full w-24 h-24 mx-auto flex items-center justify-center">
<XCircle className="h-12 w-12 text-white" />
</div>
</div>
{/* Cancel Message */}
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Payment Cancelled
</h1>
<p className="text-lg text-[#6B708D] max-w-2xl mx-auto mb-8">
Your payment was cancelled. No charges have been made to your account.
</p>
</div>
{/* Info Card */}
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] shadow-lg mb-8">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6 text-center">
What Happened?
</h2>
<div className="space-y-6 mb-8">
<p className="text-[#6B708D] text-center">
You cancelled the payment process or closed the checkout page. Your membership has not been activated yet.
</p>
<div className="bg-[#F2CC8F]/20 p-6 rounded-xl">
<h3 className="text-lg font-semibold text-[#3D405B] mb-4">
Ready to Complete Your Membership?
</h3>
<ul className="space-y-3">
<li className="flex items-start gap-3">
<CreditCard className="h-5 w-5 text-[#E07A5F] flex-shrink-0 mt-0.5" />
<span className="text-[#6B708D]">
Return to the plans page to complete your subscription
</span>
</li>
<li className="flex items-start gap-3">
<Mail className="h-5 w-5 text-[#E07A5F] flex-shrink-0 mt-0.5" />
<span className="text-[#6B708D]">
Contact us if you experienced any issues during checkout
</span>
</li>
</ul>
</div>
<div className="bg-[#FDFCF8] p-6 rounded-xl">
<p className="text-sm text-[#6B708D] text-center mb-4">
<span className="font-medium text-[#3D405B]">Note:</span>{' '}
Your membership application is still approved. You can complete payment whenever you're ready.
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
onClick={() => navigate('/plans')}
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-8 py-6 text-lg font-semibold"
data-testid="try-again-button"
>
<CreditCard className="mr-2 h-5 w-5" />
Try Again
</Button>
<Button
onClick={() => navigate('/dashboard')}
variant="outline"
className="border-2 border-[#6B708D] text-[#6B708D] hover:bg-[#6B708D] hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
data-testid="back-to-dashboard-button"
>
<ArrowLeft className="mr-2 h-5 w-5" />
Back to Dashboard
</Button>
</div>
</Card>
{/* Support Section */}
<Card className="p-6 bg-gradient-to-br from-[#F2CC8F]/20 to-[#E07A5F]/20 rounded-2xl border border-[#EAE0D5]">
<h3 className="text-lg font-semibold fraunces text-[#3D405B] mb-3 text-center">
Need Assistance?
</h3>
<p className="text-[#6B708D] text-center mb-4">
If you encountered any technical issues or have questions about the payment process, our support team is here to help.
</p>
<div className="text-center">
<a
href="mailto:support@loaf.org"
className="text-[#E07A5F] hover:text-[#D0694E] font-medium text-lg"
>
support@loaf.org
</a>
</div>
</Card>
</div>
</div>
);
};
export default PaymentCancel;

135
src/pages/PaymentSuccess.js Normal file
View File

@@ -0,0 +1,135 @@
import React, { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button';
import Navbar from '../components/Navbar';
import { CheckCircle, Calendar, User } from 'lucide-react';
const PaymentSuccess = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { refreshUser } = useAuth();
const sessionId = searchParams.get('session_id');
useEffect(() => {
// Refresh user data to get updated status after payment
if (refreshUser) {
refreshUser();
}
}, [refreshUser]);
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="text-center mb-12">
{/* Success Icon */}
<div className="mb-8">
<div className="bg-[#81B29A] rounded-full w-24 h-24 mx-auto flex items-center justify-center">
<CheckCircle className="h-12 w-12 text-white" />
</div>
</div>
{/* Success Message */}
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Payment Successful!
</h1>
<p className="text-lg text-[#6B708D] max-w-2xl mx-auto mb-8">
Thank you for your payment. Your LOAF membership is now active!
</p>
</div>
{/* Confirmation Card */}
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] shadow-lg mb-8">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6 text-center">
Welcome to the LOAF Community!
</h2>
<div className="space-y-6 mb-8">
<div className="bg-[#FDFCF8] p-6 rounded-xl">
<h3 className="text-lg font-semibold text-[#3D405B] mb-4">
What's Next?
</h3>
<ul className="space-y-3">
<li className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
<span className="text-[#6B708D]">
Your membership is now active and you have full access to all member benefits
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
<span className="text-[#6B708D]">
You can now RSVP and attend members-only events
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
<span className="text-[#6B708D]">
Access the community directory and connect with other members
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
<span className="text-[#6B708D]">
You'll receive our newsletter with exclusive updates and announcements
</span>
</li>
</ul>
</div>
{sessionId && (
<div className="bg-[#F2CC8F]/20 p-4 rounded-xl">
<p className="text-sm text-[#6B708D] text-center">
<span className="font-medium text-[#3D405B]">Transaction ID:</span>{' '}
<span className="font-mono text-xs">{sessionId}</span>
</p>
<p className="text-xs text-[#6B708D] text-center mt-2">
A confirmation email has been sent to your registered email address.
</p>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
onClick={() => navigate('/dashboard')}
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-8 py-6 text-lg font-semibold"
data-testid="go-to-dashboard-button"
>
<User className="mr-2 h-5 w-5" />
Go to Dashboard
</Button>
<Button
onClick={() => navigate('/events')}
variant="outline"
className="border-2 border-[#E07A5F] text-[#E07A5F] hover:bg-[#E07A5F] hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
data-testid="browse-events-button"
>
<Calendar className="mr-2 h-5 w-5" />
Browse Events
</Button>
</div>
</Card>
{/* Additional Info */}
<div className="text-center">
<p className="text-sm text-[#6B708D]">
Need help? Contact us at{' '}
<a
href="mailto:support@loaf.org"
className="text-[#E07A5F] hover:text-[#D0694E] font-medium"
>
support@loaf.org
</a>
</p>
</div>
</div>
</div>
);
};
export default PaymentSuccess;

196
src/pages/Plans.js Normal file
View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button';
import Navbar from '../components/Navbar';
import { CheckCircle, CreditCard, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
const Plans = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [processingPlanId, setProcessingPlanId] = useState(null);
useEffect(() => {
fetchPlans();
}, []);
const fetchPlans = async () => {
try {
const response = await api.get('/subscriptions/plans');
setPlans(response.data);
} catch (error) {
console.error('Failed to fetch plans:', error);
toast.error('Failed to load subscription plans');
} finally {
setLoading(false);
}
};
const handleSubscribe = async (planId) => {
if (!user) {
navigate('/login');
return;
}
setProcessingPlanId(planId);
try {
const response = await api.post('/subscriptions/checkout', {
plan_id: planId
});
// Redirect to Stripe Checkout
window.location.href = response.data.checkout_url;
} catch (error) {
console.error('Failed to create checkout session:', error);
toast.error(error.response?.data?.detail || 'Failed to start checkout process');
setProcessingPlanId(null);
}
};
const formatPrice = (cents) => {
return `$${(cents / 100).toFixed(2)}`;
};
const getBillingCycleLabel = (billingCycle) => {
const labels = {
yearly: 'per year',
monthly: 'per month'
};
return labels[billingCycle] || billingCycle;
};
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-7xl mx-auto px-6 py-12">
{/* Header */}
<div className="mb-12 text-center">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Membership Plans
</h1>
<p className="text-lg text-[#6B708D] max-w-2xl mx-auto">
Choose the membership plan that works best for you and become part of our vibrant community.
</p>
</div>
{loading ? (
<div className="text-center py-20">
<Loader2 className="h-12 w-12 text-[#E07A5F] mx-auto mb-4 animate-spin" />
<p className="text-[#6B708D]">Loading plans...</p>
</div>
) : plans.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-5xl mx-auto">
{plans.map((plan) => (
<Card
key={plan.id}
className="p-8 bg-white rounded-2xl border-2 border-[#EAE0D5] hover:border-[#E07A5F] hover:shadow-xl transition-all"
data-testid={`plan-card-${plan.id}`}
>
{/* Plan Header */}
<div className="text-center mb-6">
<div className="bg-[#F2CC8F]/20 p-4 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<CreditCard className="h-8 w-8 text-[#E07A5F]" />
</div>
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-2">
{plan.name}
</h2>
{plan.description && (
<p className="text-sm text-[#6B708D] mb-4">
{plan.description}
</p>
)}
</div>
{/* Pricing */}
<div className="text-center mb-8">
<div className="text-4xl font-bold fraunces text-[#3D405B] mb-2">
{formatPrice(plan.price_cents)}
</div>
<p className="text-[#6B708D]">
{getBillingCycleLabel(plan.billing_cycle)}
</p>
</div>
{/* Features */}
<div className="space-y-3 mb-8">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
<span className="text-sm text-[#3D405B]">Access to all member events</span>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
<span className="text-sm text-[#3D405B]">Community directory access</span>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
<span className="text-sm text-[#3D405B]">Exclusive member benefits</span>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
<span className="text-sm text-[#3D405B]">Newsletter subscription</span>
</div>
</div>
{/* CTA Button */}
<Button
onClick={() => handleSubscribe(plan.id)}
disabled={processingPlanId === plan.id}
className="w-full bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full py-6 text-lg font-semibold"
data-testid={`subscribe-button-${plan.id}`}
>
{processingPlanId === plan.id ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Processing...
</>
) : (
'Subscribe Now'
)}
</Button>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<CreditCard className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Plans Available
</h3>
<p className="text-[#6B708D]">
Membership plans are not currently available. Please check back later!
</p>
</div>
)}
{/* Info Section */}
<div className="mt-16 max-w-3xl mx-auto">
<Card className="p-8 bg-gradient-to-br from-[#F2CC8F]/20 to-[#E07A5F]/20 rounded-2xl border border-[#EAE0D5]">
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-4 text-center">
Need Help Choosing?
</h3>
<p className="text-[#6B708D] text-center mb-4">
If you have any questions about our membership plans or need assistance, please contact us.
</p>
<div className="text-center">
<a
href="mailto:support@loaf.org"
className="text-[#E07A5F] hover:text-[#D0694E] font-medium"
>
support@loaf.org
</a>
</div>
</Card>
</div>
</div>
</div>
);
};
export default Plans;

230
src/pages/Profile.js Normal file
View File

@@ -0,0 +1,230 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
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 { toast } from 'sonner';
import Navbar from '../components/Navbar';
import { User, Save } from 'lucide-react';
const Profile = () => {
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [profileData, setProfileData] = useState(null);
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
phone: '',
address: '',
city: '',
state: '',
zipcode: ''
});
useEffect(() => {
fetchProfile();
}, []);
const fetchProfile = async () => {
try {
const response = await api.get('/users/profile');
setProfileData(response.data);
setFormData({
first_name: response.data.first_name,
last_name: response.data.last_name,
phone: response.data.phone,
address: response.data.address,
city: response.data.city,
state: response.data.state,
zipcode: response.data.zipcode
});
} catch (error) {
toast.error('Failed to load profile');
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await api.put('/users/profile', formData);
toast.success('Profile updated successfully!');
fetchProfile();
} catch (error) {
toast.error('Failed to update profile');
} finally {
setLoading(false);
}
};
if (!profileData) {
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#6B708D]">Loading profile...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
My Profile
</h1>
<p className="text-lg text-[#6B708D]">
Update your personal information below.
</p>
</div>
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] shadow-lg">
{/* Read-only Information */}
<div className="mb-8 pb-8 border-b border-[#EAE0D5]">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6 flex items-center gap-2">
<User className="h-6 w-6 text-[#E07A5F]" />
Account Information
</h2>
<div className="grid md:grid-cols-2 gap-6">
<div>
<p className="text-sm text-[#6B708D] mb-1">Email</p>
<p className="text-[#3D405B] font-medium">{profileData.email}</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-1">Status</p>
<p className="text-[#3D405B] font-medium capitalize">{profileData.status.replace('_', ' ')}</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-1">Role</p>
<p className="text-[#3D405B] font-medium capitalize">{profileData.role}</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-1">Date of Birth</p>
<p className="text-[#3D405B] font-medium">
{new Date(profileData.date_of_birth).toLocaleDateString()}
</p>
</div>
</div>
</div>
{/* Editable Form */}
<form onSubmit={handleSubmit} className="space-y-6" data-testid="profile-form">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6">
Personal Information
</h2>
<div className="grid md:grid-cols-2 gap-6">
<div>
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="first-name-input"
/>
</div>
<div>
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="last-name-input"
/>
</div>
</div>
<div>
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
name="phone"
type="tel"
value={formData.phone}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="phone-input"
/>
</div>
<div>
<Label htmlFor="address">Address</Label>
<Input
id="address"
name="address"
value={formData.address}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="address-input"
/>
</div>
<div className="grid md:grid-cols-3 gap-6">
<div>
<Label htmlFor="city">City</Label>
<Input
id="city"
name="city"
value={formData.city}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="city-input"
/>
</div>
<div>
<Label htmlFor="state">State</Label>
<Input
id="state"
name="state"
value={formData.state}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="state-input"
/>
</div>
<div>
<Label htmlFor="zipcode">Zipcode</Label>
<Input
id="zipcode"
name="zipcode"
value={formData.zipcode}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="zipcode-input"
/>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-8 py-6 text-lg font-medium shadow-lg disabled:opacity-50"
data-testid="save-profile-button"
>
<Save className="h-5 w-5 mr-2" />
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</form>
</Card>
</div>
</div>
);
};
export default Profile;

388
src/pages/Register.js Normal file
View File

@@ -0,0 +1,388 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Card } from '../components/ui/card';
import { Checkbox } from '../components/ui/checkbox';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import { ArrowRight, ArrowLeft } from 'lucide-react';
const Register = () => {
const navigate = useNavigate();
const { register } = useAuth();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: '',
first_name: '',
last_name: '',
phone: '',
address: '',
city: '',
state: '',
zipcode: '',
date_of_birth: '',
lead_sources: [],
partner_first_name: '',
partner_last_name: '',
partner_is_member: false,
partner_plan_to_become_member: false,
referred_by_member_name: ''
});
const leadSourceOptions = [
'Current member',
'Friend',
'OutSmart Magazine',
'Search engine (Google etc.)',
"I've known about LOAF for a long time",
'Other'
];
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleLeadSourceChange = (source) => {
setFormData(prev => {
const sources = prev.lead_sources.includes(source)
? prev.lead_sources.filter(s => s !== source)
: [...prev.lead_sources, source];
return { ...prev, lead_sources: sources };
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
// Convert date to ISO format
const dataToSubmit = {
...formData,
date_of_birth: new Date(formData.date_of_birth).toISOString()
};
await register(dataToSubmit);
toast.success('Registration successful! Please check your email to verify your account.');
navigate('/login');
} catch (error) {
toast.error(error.response?.data?.detail || 'Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-8">
<Link to="/" className="inline-flex items-center text-[#6B708D] hover:text-[#E07A5F] transition-colors">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Home
</Link>
</div>
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#EAE0D5] shadow-lg">
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Join Our Community
</h1>
<p className="text-lg text-[#6B708D]">
Fill out the form below to start your membership journey.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-8" data-testid="register-form">
{/* Account Information */}
<div className="space-y-4">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B]">
Account Information
</h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="email">Email *</Label>
<Input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="email-input"
/>
</div>
<div>
<Label htmlFor="password">Password *</Label>
<Input
id="password"
name="password"
type="password"
required
minLength={6}
value={formData.password}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="password-input"
/>
</div>
</div>
</div>
{/* Personal Information */}
<div className="space-y-4">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B]">
Personal Information
</h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="first_name">First Name *</Label>
<Input
id="first_name"
name="first_name"
required
value={formData.first_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="first-name-input"
/>
</div>
<div>
<Label htmlFor="last_name">Last Name *</Label>
<Input
id="last_name"
name="last_name"
required
value={formData.last_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="last-name-input"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="phone">Phone *</Label>
<Input
id="phone"
name="phone"
type="tel"
required
value={formData.phone}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="phone-input"
/>
</div>
<div>
<Label htmlFor="date_of_birth">Date of Birth *</Label>
<Input
id="date_of_birth"
name="date_of_birth"
type="date"
required
value={formData.date_of_birth}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="dob-input"
/>
</div>
</div>
<div>
<Label htmlFor="address">Address *</Label>
<Input
id="address"
name="address"
required
value={formData.address}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="address-input"
/>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label htmlFor="city">City *</Label>
<Input
id="city"
name="city"
required
value={formData.city}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="city-input"
/>
</div>
<div>
<Label htmlFor="state">State *</Label>
<Input
id="state"
name="state"
required
value={formData.state}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="state-input"
/>
</div>
<div>
<Label htmlFor="zipcode">Zipcode *</Label>
<Input
id="zipcode"
name="zipcode"
required
value={formData.zipcode}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="zipcode-input"
/>
</div>
</div>
</div>
{/* How Did You Hear About Us */}
<div className="space-y-4">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B]">
How Did You Hear About Us? *
</h2>
<div className="space-y-3">
{leadSourceOptions.map((source) => (
<div key={source} className="flex items-center space-x-2">
<Checkbox
id={source}
checked={formData.lead_sources.includes(source)}
onCheckedChange={() => handleLeadSourceChange(source)}
data-testid={`lead-source-${source.toLowerCase().replace(/\s+/g, '-')}`}
/>
<Label htmlFor={source} className="text-base cursor-pointer">
{source}
</Label>
</div>
))}
</div>
</div>
{/* Partner Information */}
<div className="space-y-4">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B]">
Partner Information (Optional)
</h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="partner_first_name">Partner First Name</Label>
<Input
id="partner_first_name"
name="partner_first_name"
value={formData.partner_first_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="partner-first-name-input"
/>
</div>
<div>
<Label htmlFor="partner_last_name">Partner Last Name</Label>
<Input
id="partner_last_name"
name="partner_last_name"
value={formData.partner_last_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="partner-last-name-input"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="partner_is_member"
name="partner_is_member"
checked={formData.partner_is_member}
onCheckedChange={(checked) =>
setFormData(prev => ({ ...prev, partner_is_member: checked }))
}
data-testid="partner-is-member-checkbox"
/>
<Label htmlFor="partner_is_member" className="text-base cursor-pointer">
Is your partner already a member?
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="partner_plan_to_become_member"
name="partner_plan_to_become_member"
checked={formData.partner_plan_to_become_member}
onCheckedChange={(checked) =>
setFormData(prev => ({ ...prev, partner_plan_to_become_member: checked }))
}
data-testid="partner-plan-member-checkbox"
/>
<Label htmlFor="partner_plan_to_become_member" className="text-base cursor-pointer">
Does your partner plan to become a member?
</Label>
</div>
</div>
</div>
{/* Referral */}
<div className="space-y-4">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B]">
Referral (Optional)
</h2>
<div>
<Label htmlFor="referred_by_member_name">Referred by Member (Name or Email)</Label>
<Input
id="referred_by_member_name"
name="referred_by_member_name"
value={formData.referred_by_member_name}
onChange={handleInputChange}
placeholder="If a current member referred you, enter their name or email"
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="referral-input"
/>
<p className="text-sm text-[#6B708D] mt-2">
If referred by a current member, you may skip the event attendance requirement.
</p>
</div>
</div>
{/* Submit Button */}
<div className="pt-6">
<Button
type="submit"
disabled={loading || formData.lead_sources.length === 0}
className="w-full bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="submit-register-button"
>
{loading ? 'Creating Account...' : 'Create Account'}
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
<p className="text-center text-[#6B708D] mt-4">
Already have an account?{' '}
<Link to="/login" className="text-[#E07A5F] hover:underline font-medium">
Login here
</Link>
</p>
</div>
</form>
</Card>
</div>
</div>
);
};
export default Register;

105
src/pages/VerifyEmail.js Normal file
View File

@@ -0,0 +1,105 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
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';
const API_URL = process.env.REACT_APP_BACKEND_URL;
const VerifyEmail = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [status, setStatus] = useState('loading');
const [message, setMessage] = useState('');
const token = searchParams.get('token');
useEffect(() => {
const verifyEmail = async () => {
if (!token) {
setStatus('error');
setMessage('Invalid verification link.');
return;
}
try {
const response = await axios.get(`${API_URL}/api/auth/verify-email?token=${token}`);
setStatus('success');
setMessage(response.data.message);
} catch (error) {
setStatus('error');
setMessage(error.response?.data?.detail || 'Verification failed. Please try again.');
}
};
verifyEmail();
}, [token]);
return (
<div className="min-h-screen bg-[#FDFCF8]">
<Navbar />
<div className="max-w-2xl mx-auto px-6 py-20">
<Card className="p-12 bg-white rounded-2xl border border-[#EAE0D5] shadow-lg text-center">
{status === 'loading' && (
<>
<Loader2 className="h-20 w-20 text-[#E07A5F] mx-auto mb-6 animate-spin" />
<h1 className="text-3xl font-semibold fraunces text-[#3D405B] mb-4">
Verifying Your Email...
</h1>
<p className="text-lg text-[#6B708D]">
Please wait while we verify your email address.
</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="h-20 w-20 text-[#81B29A] mx-auto mb-6" />
<h1 className="text-3xl font-semibold fraunces text-[#3D405B] mb-4">
Email Verified Successfully!
</h1>
<p className="text-lg text-[#6B708D] mb-8">
{message}
</p>
<p className="text-base text-[#6B708D] mb-8">
Next steps: Attend an event and meet a board member within 90 days. Once you've attended an event, our admin team will review your application.
</p>
<Link to="/login">
<Button
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-12 py-6 text-lg font-medium shadow-lg"
data-testid="login-redirect-button"
>
Go to Login
</Button>
</Link>
</>
)}
{status === 'error' && (
<>
<XCircle className="h-20 w-20 text-[#E07A5F] mx-auto mb-6" />
<h1 className="text-3xl font-semibold fraunces text-[#3D405B] mb-4">
Verification Failed
</h1>
<p className="text-lg text-[#6B708D] mb-8">
{message}
</p>
<Link to="/">
<Button
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-12 py-6 text-lg font-medium shadow-lg"
data-testid="home-redirect-button"
>
Go to Home
</Button>
</Link>
</>
)}
</Card>
</div>
</div>
);
};
export default VerifyEmail;

View File

@@ -0,0 +1,469 @@
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '../../components/ui/table';
import {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
} from '../../components/ui/pagination';
import { toast } from 'sonner';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
const AdminApprovals = () => {
const [pendingUsers, setPendingUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null);
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
// Filtering state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Sorting state
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
useEffect(() => {
fetchPendingUsers();
}, []);
useEffect(() => {
filterAndSortUsers();
}, [pendingUsers, searchQuery, statusFilter, sortBy, sortOrder]);
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, statusFilter]);
const fetchPendingUsers = async () => {
try {
const response = await api.get('/admin/users');
const pending = response.data.filter(user =>
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(user.status)
);
setPendingUsers(pending);
} catch (error) {
toast.error('Failed to fetch pending users');
} finally {
setLoading(false);
}
};
const filterAndSortUsers = () => {
let filtered = [...pendingUsers];
// Apply status filter
if (statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
// Apply search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.phone?.toLowerCase().includes(query)
);
}
// Apply sorting
filtered.sort((a, b) => {
let aVal = a[sortBy];
let bVal = b[sortBy];
if (sortBy === 'created_at') {
aVal = new Date(aVal);
bVal = new Date(bVal);
} else if (sortBy === 'first_name') {
aVal = `${a.first_name} ${a.last_name}`;
bVal = `${b.first_name} ${b.last_name}`;
}
if (sortOrder === 'asc') {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
setFilteredUsers(filtered);
};
const handleApprove = async (userId) => {
setActionLoading(userId);
try {
await api.put(`/admin/users/${userId}/approve`);
toast.success('User validated and approved! Payment email sent.');
fetchPendingUsers();
} catch (error) {
toast.error('Failed to approve user');
} finally {
setActionLoading(null);
}
};
const handleBypassAndApprove = async (userId) => {
if (!window.confirm(
'This will bypass email verification and approve the user. ' +
'Are you sure you want to proceed?'
)) {
return;
}
setActionLoading(userId);
try {
await api.put(`/admin/users/${userId}/approve?bypass_email_verification=true`);
toast.success('User email verified and approved! Payment email sent.');
fetchPendingUsers();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to approve user');
} finally {
setActionLoading(null);
}
};
const handleActivatePayment = (user) => {
setSelectedUserForPayment(user);
setPaymentDialogOpen(true);
};
const handlePaymentSuccess = () => {
fetchPendingUsers(); // Refresh list
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Awaiting Email', className: 'bg-[#F2CC8F] text-[#3D405B]' },
pending_approval: { label: 'Pending', className: 'bg-[#A3B1C6] text-white' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-[#E07A5F] text-white' }
};
const statusConfig = config[status];
return (
<Badge className={`${statusConfig.className} px-2 py-1 rounded-full text-xs`}>
{statusConfig.label}
</Badge>
);
};
const handleSort = (column) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('asc');
}
};
// Pagination calculations
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
const paginatedUsers = filteredUsers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const renderSortIcon = (column) => {
if (sortBy !== column) return null;
return sortOrder === 'asc' ?
<ArrowUp className="h-4 w-4 inline ml-1" /> :
<ArrowDown className="h-4 w-4 inline ml-1" />;
};
return (
<>
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Approval Queue
</h1>
<p className="text-lg text-[#6B708D]">
Review and approve pending membership applications.
</p>
</div>
{/* Stats Card */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div>
<p className="text-sm text-[#6B708D] mb-2">Total Pending</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.length}
</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-2">Awaiting Email</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.filter(u => u.status === 'pending_email').length}
</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-2">Pending Approval</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.filter(u => u.status === 'pending_approval').length}
</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-2">Pre-Approved</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.filter(u => u.status === 'pre_approved').length}
</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-2">Payment Pending</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.filter(u => u.status === 'payment_pending').length}
</p>
</div>
</div>
</Card>
{/* Filter Card */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid md:grid-cols-3 gap-4">
<div className="relative md:col-span-2">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#6B708D]" />
<Input
placeholder="Search by name, email, or phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Awaiting Email</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Table */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading pending applications...</p>
</div>
) : filteredUsers.length > 0 ? (
<>
<Card className="bg-white rounded-2xl border border-[#EAE0D5] overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-[#F2CC8F]/20"
onClick={() => handleSort('first_name')}
>
Name {renderSortIcon('first_name')}
</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead
className="cursor-pointer hover:bg-[#F2CC8F]/20"
onClick={() => handleSort('status')}
>
Status {renderSortIcon('status')}
</TableHead>
<TableHead
className="cursor-pointer hover:bg-[#F2CC8F]/20"
onClick={() => handleSort('created_at')}
>
Registered {renderSortIcon('created_at')}
</TableHead>
<TableHead>Referred By</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.first_name} {user.last_name}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>{getStatusBadge(user.status)}</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
{user.referred_by_member_name || '-'}
</TableCell>
<TableCell>
<div className="flex gap-2">
{user.status === 'pending_email' ? (
<Button
onClick={() => handleBypassAndApprove(user.id)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#E07A5F] text-white hover:bg-[#D0694E]"
>
{actionLoading === user.id ? 'Approving...' : 'Bypass & Approve'}
</Button>
) : user.status === 'payment_pending' ? (
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="bg-[#E07A5F] text-white hover:bg-[#D0694E]"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
) : (
<Button
onClick={() => handleApprove(user.id)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
>
{actionLoading === user.id ? 'Validating...' : 'Approve'}
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Pagination Controls */}
<div className="mt-8 flex flex-col md:flex-row justify-between items-center gap-4">
{/* Page size selector */}
<div className="flex items-center gap-2">
<p className="text-sm text-[#6B708D]">Show</p>
<Select
value={itemsPerPage.toString()}
onValueChange={(val) => {
setItemsPerPage(parseInt(val));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-[#6B708D]">
entries (showing {(currentPage - 1) * itemsPerPage + 1}-
{Math.min(currentPage * itemsPerPage, filteredUsers.length)} of {filteredUsers.length})
</p>
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
{[...Array(totalPages)].map((_, i) => {
const showPage = i < 2 || i >= totalPages - 2 ||
Math.abs(i - currentPage + 1) <= 1;
if (!showPage && i === 2) {
return (
<PaginationItem key={i}>
<PaginationEllipsis />
</PaginationItem>
);
}
if (!showPage) return null;
return (
<PaginationItem key={i}>
<PaginationLink
onClick={() => setCurrentPage(i + 1)}
isActive={currentPage === i + 1}
className="cursor-pointer"
>
{i + 1}
</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</>
) : (
<div className="text-center py-20">
<Clock className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Pending Approvals
</h3>
<p className="text-[#6B708D]">
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters'
: 'All applications have been reviewed!'}
</p>
</div>
)}
{/* Payment Activation Dialog */}
<PaymentActivationDialog
open={paymentDialogOpen}
onOpenChange={setPaymentDialogOpen}
user={selectedUserForPayment}
onSuccess={handlePaymentSuccess}
/>
</>
);
};
export default AdminApprovals;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Users, Calendar, Clock, CheckCircle } from 'lucide-react';
const AdminDashboard = () => {
const [stats, setStats] = useState({
totalMembers: 0,
pendingApprovals: 0,
activeMembers: 0
});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardStats();
}, []);
const fetchDashboardStats = async () => {
try {
const usersResponse = await api.get('/admin/users');
const users = usersResponse.data;
setStats({
totalMembers: users.filter(u => u.role === 'member').length,
pendingApprovals: users.filter(u =>
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(u.status)
).length,
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length
});
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
};
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Admin Dashboard
</h1>
<p className="text-lg text-[#6B708D]">
Manage users, events, and membership applications.
</p>
</div>
{/* Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]" data-testid="stat-total-users">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#A3B1C6]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-[#A3B1C6]" />
</div>
</div>
<p className="text-3xl font-semibold fraunces text-[#3D405B] mb-1">
{loading ? '-' : stats.totalMembers}
</p>
<p className="text-sm text-[#6B708D]">Total Members</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]" data-testid="stat-pending-approvals">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#F2CC8F]/20 p-3 rounded-lg">
<Clock className="h-6 w-6 text-[#E07A5F]" />
</div>
</div>
<p className="text-3xl font-semibold fraunces text-[#3D405B] mb-1">
{loading ? '-' : stats.pendingApprovals}
</p>
<p className="text-sm text-[#6B708D]">Pending Approvals</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]" data-testid="stat-active-members">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#81B29A]/20 p-3 rounded-lg">
<CheckCircle className="h-6 w-6 text-[#81B29A]" />
</div>
</div>
<p className="text-3xl font-semibold fraunces text-[#3D405B] mb-1">
{loading ? '-' : stats.activeMembers}
</p>
<p className="text-sm text-[#6B708D]">Active Members</p>
</Card>
</div>
{/* Quick Actions */}
<div className="grid md:grid-cols-2 gap-8">
<Link to="/admin/users">
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
<Users className="h-12 w-12 text-[#E07A5F] mb-4" />
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-2">
Manage Users
</h3>
<p className="text-[#6B708D]">
View and manage all registered users and their membership status.
</p>
<Button
className="mt-4 bg-[#F2CC8F] text-[#3D405B] hover:bg-[#E5B875] rounded-full"
data-testid="manage-users-button"
>
Go to Users
</Button>
</Card>
</Link>
<Link to="/admin/approvals">
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-approvals">
<Clock className="h-12 w-12 text-[#E07A5F] mb-4" />
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-2">
Approval Queue
</h3>
<p className="text-[#6B708D]">
Review and approve pending membership applications.
</p>
<Button
className="mt-4 bg-[#F2CC8F] text-[#3D405B] hover:bg-[#E5B875] rounded-full"
data-testid="manage-approvals-button"
>
View Approvals
</Button>
</Card>
</Link>
</div>
</>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,430 @@
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
import { toast } from 'sonner';
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
import { AttendanceDialog } from '../../components/AttendanceDialog';
const AdminEvents = () => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState(null);
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState(null);
const [formData, setFormData] = useState({
title: '',
description: '',
start_at: '',
end_at: '',
location: '',
capacity: '',
published: false
});
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
const response = await api.get('/admin/events');
setEvents(response.data);
} catch (error) {
toast.error('Failed to fetch events');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const eventData = {
...formData,
capacity: formData.capacity ? parseInt(formData.capacity) : null,
start_at: new Date(formData.start_at).toISOString(),
end_at: new Date(formData.end_at).toISOString()
};
if (editingEvent) {
await api.put(`/admin/events/${editingEvent.id}`, eventData);
toast.success('Event updated successfully');
} else {
await api.post('/admin/events', eventData);
toast.success('Event created successfully');
}
setIsCreateDialogOpen(false);
setEditingEvent(null);
resetForm();
fetchEvents();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save event');
}
};
const handleEdit = (event) => {
setEditingEvent(event);
setFormData({
title: event.title,
description: event.description || '',
start_at: new Date(event.start_at).toISOString().slice(0, 16),
end_at: new Date(event.end_at).toISOString().slice(0, 16),
location: event.location,
capacity: event.capacity || '',
published: event.published
});
setIsCreateDialogOpen(true);
};
const handleDelete = async (eventId) => {
if (!window.confirm('Are you sure you want to delete this event?')) {
return;
}
try {
await api.delete(`/admin/events/${eventId}`);
toast.success('Event deleted successfully');
fetchEvents();
} catch (error) {
toast.error('Failed to delete event');
}
};
const togglePublish = async (event) => {
try {
await api.put(`/admin/events/${event.id}`, {
published: !event.published
});
toast.success(event.published ? 'Event unpublished' : 'Event published');
fetchEvents();
} catch (error) {
toast.error('Failed to update event');
}
};
const resetForm = () => {
setFormData({
title: '',
description: '',
start_at: '',
end_at: '',
location: '',
capacity: '',
published: false
});
};
const handleDialogClose = () => {
setIsCreateDialogOpen(false);
setEditingEvent(null);
resetForm();
};
return (
<>
{/* Header */}
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Event Management
</h1>
<p className="text-lg text-[#6B708D]">
Create and manage community events.
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
resetForm();
setEditingEvent(null);
}}
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-6"
data-testid="create-event-button"
>
<Plus className="mr-2 h-5 w-5" />
Create Event
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl fraunces text-[#3D405B]">
{editingEvent ? 'Edit Event' : 'Create New Event'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Title *
</label>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
className="w-full border-2 border-[#EAE0D5] focus:border-[#E07A5F] rounded-lg p-3"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Start Date & Time *
</label>
<Input
type="datetime-local"
value={formData.start_at}
onChange={(e) => setFormData({ ...formData, start_at: e.target.value })}
required
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
End Date & Time *
</label>
<Input
type="datetime-local"
value={formData.end_at}
onChange={(e) => setFormData({ ...formData, end_at: e.target.value })}
required
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Location *
</label>
<Input
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
required
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Capacity (optional)
</label>
<Input
type="number"
value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: e.target.value })}
placeholder="Leave empty for unlimited"
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="published"
checked={formData.published}
onChange={(e) => setFormData({ ...formData, published: e.target.checked })}
className="w-4 h-4 text-[#E07A5F] border-[#EAE0D5] rounded focus:ring-[#E07A5F]"
/>
<label htmlFor="published" className="text-sm font-medium text-[#3D405B]">
Publish event (make visible to members)
</label>
</div>
<div className="flex gap-3 pt-4">
<Button
type="submit"
className="flex-1 bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full"
>
{editingEvent ? 'Update Event' : 'Create Event'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleDialogClose}
className="flex-1 border-2 border-[#6B708D] text-[#6B708D] hover:bg-[#6B708D] hover:text-white rounded-full"
>
Cancel
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
{/* Events List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading events...</p>
</div>
) : events.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{events.map((event) => (
<Card
key={event.id}
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg transition-all"
data-testid={`event-card-${event.id}`}
>
{/* Event Header */}
<div className="flex justify-between items-start mb-4">
<div className="bg-[#F2CC8F]/20 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-[#E07A5F]" />
</div>
<Badge
className={`${
event.published
? 'bg-[#81B29A] text-white'
: 'bg-[#6B708D] text-white'
} px-3 py-1 rounded-full`}
>
{event.published ? 'Published' : 'Draft'}
</Badge>
</div>
{/* Event Details */}
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-3">
{event.title}
</h3>
{event.description && (
<p className="text-[#6B708D] mb-4 line-clamp-2 text-sm">
{event.description}
</p>
)}
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-[#6B708D]">
<Calendar className="h-4 w-4" />
<span>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-[#6B708D]">
<MapPin className="h-4 w-4" />
<span className="truncate">{event.location}</span>
</div>
<div className="flex items-center gap-2 text-sm text-[#6B708D]">
<Users className="h-4 w-4" />
<span>{event.rsvp_count || 0} attending</span>
</div>
</div>
{/* Actions */}
<div className="space-y-2 pt-4 border-t border-[#EAE0D5]">
{/* Mark Attendance Button */}
<Button
onClick={() => {
setSelectedEvent(event);
setAttendanceDialogOpen(true);
}}
variant="outline"
size="sm"
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
data-testid={`mark-attendance-${event.id}`}
>
<Users className="h-4 w-4 mr-2" />
Mark Attendance ({event.rsvp_count || 0} RSVPs)
</Button>
{/* Other Actions */}
<div className="flex gap-2">
<Button
onClick={() => togglePublish(event)}
variant="outline"
size="sm"
className="flex-1 border-[#E07A5F] text-[#E07A5F] hover:bg-[#E07A5F] hover:text-white"
data-testid={`toggle-publish-${event.id}`}
>
{event.published ? (
<>
<EyeOff className="h-4 w-4 mr-1" />
Unpublish
</>
) : (
<>
<Eye className="h-4 w-4 mr-1" />
Publish
</>
)}
</Button>
<Button
onClick={() => handleEdit(event)}
variant="outline"
size="sm"
className="border-[#6B708D] text-[#6B708D] hover:bg-[#6B708D] hover:text-white"
data-testid={`edit-event-${event.id}`}
>
<Edit className="h-4 w-4" />
</Button>
<Button
onClick={() => handleDelete(event.id)}
variant="outline"
size="sm"
className="border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
data-testid={`delete-event-${event.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<Calendar className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Events Yet
</h3>
<p className="text-[#6B708D] mb-6">
Create your first event to get started!
</p>
<Button
onClick={() => setIsCreateDialogOpen(true)}
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-8"
>
<Plus className="mr-2 h-5 w-5" />
Create Event
</Button>
</div>
)}
{/* Attendance Dialog */}
<AttendanceDialog
event={selectedEvent}
open={attendanceDialogOpen}
onOpenChange={setAttendanceDialogOpen}
onSuccess={fetchEvents}
/>
</>
);
};
export default AdminEvents;

View File

@@ -0,0 +1,294 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { toast } from 'sonner';
import { Users, Search, User, CreditCard, Eye, CheckCircle } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
const AdminMembers = () => {
const navigate = useNavigate();
const location = useLocation();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('active');
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
const tabs = [
{ name: 'All Users', path: '/admin/users' },
{ name: 'Staff', path: '/admin/staff' },
{ name: 'Members', path: '/admin/members' }
];
useEffect(() => {
fetchMembers();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, statusFilter]);
const fetchMembers = async () => {
try {
const response = await api.get('/admin/users');
// Filter to only members
const members = response.data.filter(user => user.role === 'member');
setUsers(members);
} catch (error) {
toast.error('Failed to fetch members');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (statusFilter && statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const handleActivatePayment = (user) => {
setSelectedUserForPayment(user);
setPaymentDialogOpen(true);
};
const handlePaymentSuccess = () => {
fetchMembers(); // Refresh list
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Pending Email', className: 'bg-[#F2CC8F] text-[#3D405B]' },
pending_approval: { label: 'Pending Approval', className: 'bg-[#A3B1C6] text-white' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-[#E07A5F] text-white' },
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { label: 'Inactive', className: 'bg-[#6B708D] text-white' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Members Management
</h1>
<p className="text-lg text-[#6B708D]">
Manage paying members and their subscriptions.
</p>
</div>
{/* Tab Navigation */}
<div className="border-b border-[#EAE0D5] mb-8">
<nav className="flex gap-8">
{tabs.map((tab) => (
<button
key={tab.path}
onClick={() => navigate(tab.path)}
className={`
pb-4 px-2 font-medium transition-colors relative
${location.pathname === tab.path
? 'text-[#E07A5F]'
: 'text-[#6B708D] hover:text-[#3D405B]'
}
`}
>
{tab.name}
{location.pathname === tab.path && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#E07A5F]" />
)}
</button>
))}
</nav>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Total Members</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Active</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.status === 'active').length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Payment Pending</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.status === 'payment_pending').length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Inactive</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.status === 'inactive').length}
</p>
</Card>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#6B708D]" />
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="search-members-input"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]" data-testid="status-filter-select">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Members List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading members...</p>
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-md transition-shadow"
data-testid={`member-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1">
{/* Avatar */}
<div className="h-14 w-14 rounded-full bg-[#F2CC8F] flex items-center justify-center text-[#3D405B] font-semibold text-lg flex-shrink-0">
{user.first_name?.[0]}{user.last_name?.[0]}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<h3 className="text-xl font-semibold fraunces text-[#3D405B]">
{user.first_name} {user.last_name}
</h3>
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#6B708D]">
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Link to={`/admin/users/${user.id}`}>
<Button
variant="outline"
size="sm"
className="border-[#A3B1C6] text-[#A3B1C6] hover:bg-[#A3B1C6] hover:text-white"
>
<Eye className="h-4 w-4 mr-1" />
View Profile
</Button>
</Link>
{/* Show Activate Payment button for payment_pending users */}
{user.status === 'payment_pending' && (
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="bg-[#E07A5F] text-white hover:bg-[#D0694E]"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
)}
{/* Show Subscription button for active users */}
{user.status === 'active' && (
<Button
variant="outline"
size="sm"
className="border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
>
<CreditCard className="h-4 w-4 mr-1" />
Subscription
</Button>
)}
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<Users className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Members Found
</h3>
<p className="text-[#6B708D]">
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters'
: 'No members yet'}
</p>
</div>
)}
{/* Payment Activation Dialog */}
<PaymentActivationDialog
open={paymentDialogOpen}
onOpenChange={setPaymentDialogOpen}
user={selectedUserForPayment}
onSuccess={handlePaymentSuccess}
/>
</>
);
};
export default AdminMembers;

View File

@@ -0,0 +1,365 @@
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import PlanDialog from '../../components/PlanDialog';
import { toast } from 'sonner';
import {
CreditCard,
Plus,
Edit,
Trash2,
Users,
Search,
DollarSign
} from 'lucide-react';
const AdminPlans = () => {
const [plans, setPlans] = useState([]);
const [filteredPlans, setFilteredPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [activeFilter, setActiveFilter] = useState('all');
const [planDialogOpen, setPlanDialogOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [planToDelete, setPlanToDelete] = useState(null);
useEffect(() => {
fetchPlans();
}, []);
useEffect(() => {
filterPlans();
}, [plans, searchQuery, activeFilter]);
const fetchPlans = async () => {
try {
const response = await api.get('/admin/subscriptions/plans');
setPlans(response.data);
} catch (error) {
toast.error('Failed to fetch plans');
} finally {
setLoading(false);
}
};
const filterPlans = () => {
let filtered = plans;
if (activeFilter !== 'all') {
filtered = filtered.filter(plan =>
activeFilter === 'active' ? plan.active : !plan.active
);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(plan =>
plan.name.toLowerCase().includes(query) ||
plan.description?.toLowerCase().includes(query)
);
}
setFilteredPlans(filtered);
};
const handleCreatePlan = () => {
setSelectedPlan(null);
setPlanDialogOpen(true);
};
const handleEditPlan = (plan) => {
setSelectedPlan(plan);
setPlanDialogOpen(true);
};
const handleDeleteClick = (plan) => {
setPlanToDelete(plan);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
try {
await api.delete(`/admin/subscriptions/plans/${planToDelete.id}`);
toast.success('Plan deleted successfully');
fetchPlans();
setDeleteDialogOpen(false);
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete plan');
}
};
const formatPrice = (cents) => {
return `$${(cents / 100).toFixed(2)}`;
};
const getBillingCycleLabel = (cycle) => {
const labels = {
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
lifetime: 'Lifetime'
};
return labels[cycle] || cycle;
};
return (
<>
<div className="mb-8">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Subscription Plans
</h1>
<p className="text-lg text-[#6B708D]">
Manage membership plans and pricing.
</p>
</div>
<Button
onClick={handleCreatePlan}
className="bg-[#E07A5F] hover:bg-[#D0694E] text-white rounded-full px-6"
>
<Plus className="h-4 w-4 mr-2" />
Create Plan
</Button>
</div>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Total Plans</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{plans.length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Active Plans</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{plans.filter(p => p.active).length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Total Subscribers</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{plans.reduce((sum, p) => sum + (p.subscriber_count || 0), 0)}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Revenue (Annual Est.)</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{formatPrice(
plans.reduce((sum, p) => {
const annualPrice = p.billing_cycle === 'yearly'
? p.price_cents
: p.price_cents * 12;
return sum + (annualPrice * (p.subscriber_count || 0));
}, 0)
)}
</p>
</Card>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#6B708D]" />
<Input
placeholder="Search plans..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Plans</SelectItem>
<SelectItem value="active">Active Only</SelectItem>
<SelectItem value="inactive">Inactive Only</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Plans Grid */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading plans...</p>
</div>
) : filteredPlans.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPlans.map((plan) => (
<Card
key={plan.id}
className={`p-6 bg-white rounded-2xl border-2 transition-all hover:shadow-lg ${
plan.active
? 'border-[#EAE0D5] hover:border-[#E07A5F]'
: 'border-[#6B708D] opacity-60'
}`}
>
{/* Header with badges */}
<div className="flex justify-between items-start mb-4">
<Badge
className={`${
plan.active
? 'bg-[#81B29A] text-white'
: 'bg-[#6B708D] text-white'
}`}
>
{plan.active ? 'Active' : 'Inactive'}
</Badge>
{plan.subscriber_count > 0 && (
<Badge className="bg-[#F2CC8F] text-[#3D405B]">
<Users className="h-3 w-3 mr-1" />
{plan.subscriber_count}
</Badge>
)}
</div>
{/* Plan Name */}
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-2">
{plan.name}
</h3>
{/* Description */}
{plan.description && (
<p className="text-sm text-[#6B708D] mb-4 line-clamp-2">
{plan.description}
</p>
)}
{/* Price */}
<div className="mb-4">
<div className="text-3xl font-bold fraunces text-[#E07A5F]">
{formatPrice(plan.price_cents)}
</div>
<p className="text-sm text-[#6B708D]">
{getBillingCycleLabel(plan.billing_cycle)}
</p>
</div>
{/* Stripe Integration Status */}
<div className="mb-6">
{plan.stripe_price_id ? (
<Badge className="bg-[#81B29A] text-white text-xs">
<DollarSign className="h-3 w-3 mr-1" />
Stripe Integrated
</Badge>
) : (
<Badge className="bg-[#F2CC8F] text-[#3D405B] text-xs">
Manual/Test Plan
</Badge>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-4 border-t border-[#EAE0D5]">
<Button
onClick={() => handleEditPlan(plan)}
variant="outline"
size="sm"
className="flex-1 border-[#A3B1C6] text-[#A3B1C6] hover:bg-[#A3B1C6] hover:text-white rounded-full"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
<Button
onClick={() => handleDeleteClick(plan)}
variant="outline"
size="sm"
className="flex-1 border-[#E07A5F] text-[#E07A5F] hover:bg-[#E07A5F] hover:text-white rounded-full"
disabled={plan.subscriber_count > 0}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</div>
{/* Warning for plans with subscribers */}
{plan.subscriber_count > 0 && (
<p className="text-xs text-[#6B708D] mt-2 text-center">
Cannot delete plan with active subscribers
</p>
)}
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<CreditCard className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Plans Found
</h3>
<p className="text-[#6B708D] mb-6">
{searchQuery || activeFilter !== 'all'
? 'Try adjusting your filters'
: 'Create your first subscription plan to get started'}
</p>
{!searchQuery && activeFilter === 'all' && (
<Button
onClick={handleCreatePlan}
className="bg-[#E07A5F] hover:bg-[#D0694E] text-white rounded-full px-8"
>
<Plus className="h-4 w-4 mr-2" />
Create First Plan
</Button>
)}
</div>
)}
{/* Plan Dialog */}
<PlanDialog
open={planDialogOpen}
onOpenChange={setPlanDialogOpen}
plan={selectedPlan}
onSuccess={fetchPlans}
/>
{/* Delete Confirmation Dialog */}
{deleteDialogOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="p-8 bg-white rounded-2xl max-w-md mx-4">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
Delete Plan
</h2>
<p className="text-[#6B708D] mb-6">
Are you sure you want to delete "{planToDelete?.name}"? This action
will deactivate the plan and it won't be available for new subscriptions.
</p>
<div className="flex gap-4">
<Button
onClick={() => setDeleteDialogOpen(false)}
variant="outline"
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
className="flex-1 bg-[#E07A5F] hover:bg-[#D0694E] text-white"
>
Delete Plan
</Button>
</div>
</Card>
</div>
)}
</>
);
};
export default AdminPlans;

View File

@@ -0,0 +1,223 @@
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { toast } from 'sonner';
import { UserCog, Search, Shield } from 'lucide-react';
const AdminStaff = () => {
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
// Staff roles (non-guest, non-member)
const STAFF_ROLES = ['admin'];
useEffect(() => {
fetchStaff();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, roleFilter]);
const fetchStaff = async () => {
try {
const response = await api.get('/admin/users');
// Filter to only staff roles
const staffUsers = response.data.filter(user =>
STAFF_ROLES.includes(user.role)
);
setUsers(staffUsers);
} catch (error) {
toast.error('Failed to fetch staff');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (roleFilter && roleFilter !== 'all') {
filtered = filtered.filter(user => user.role === roleFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const getRoleBadge = (role) => {
const config = {
superadmin: { label: 'Superadmin', className: 'bg-[#E07A5F] text-white' },
admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
moderator: { label: 'Moderator', className: 'bg-[#A3B1C6] text-white' },
staff: { label: 'Staff', className: 'bg-[#F2CC8F] text-[#3D405B]' },
media: { label: 'Media', className: 'bg-[#6B708D] text-white' }
};
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
return (
<Badge className={`${roleConfig.className} px-3 py-1 rounded-full text-sm`}>
<Shield className="h-3 w-3 mr-1 inline" />
{roleConfig.label}
</Badge>
);
};
const getStatusBadge = (status) => {
const config = {
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { label: 'Inactive', className: 'bg-[#6B708D] text-white' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Staff Management
</h1>
<p className="text-lg text-[#6B708D]">
Manage internal team members and their roles.
</p>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Total Staff</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Admins</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => ['admin', 'superadmin'].includes(u.role)).length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Moderators</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.role === 'moderator').length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Active</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.status === 'active').length}
</p>
</Card>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#6B708D]" />
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="search-staff-input"
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]" data-testid="role-filter-select">
<SelectValue placeholder="Filter by role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="superadmin">Superadmin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="staff">Staff</SelectItem>
<SelectItem value="media">Media</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Staff List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading staff...</p>
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-md transition-shadow"
data-testid={`staff-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1">
{/* Avatar */}
<div className="h-14 w-14 rounded-full bg-[#F2CC8F] flex items-center justify-center text-[#3D405B] font-semibold text-lg flex-shrink-0">
{user.first_name?.[0]}{user.last_name?.[0]}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<h3 className="text-xl font-semibold fraunces text-[#3D405B]">
{user.first_name} {user.last_name}
</h3>
{getRoleBadge(user.role)}
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#6B708D]">
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.last_login && (
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
)}
</div>
</div>
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<UserCog className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Staff Found
</h3>
<p className="text-[#6B708D]">
{searchQuery || roleFilter !== 'all'
? 'Try adjusting your filters'
: 'No staff members yet'}
</p>
</div>
)}
</>
);
};
export default AdminStaff;

View File

@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { ArrowLeft, Mail, Phone, MapPin, Calendar } from 'lucide-react';
import { toast } from 'sonner';
const AdminUserView = () => {
const { userId } = useParams();
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUserProfile();
}, [userId]);
const fetchUserProfile = async () => {
try {
const response = await api.get(`/admin/users/${userId}`);
setUser(response.data);
} catch (error) {
toast.error('Failed to load user profile');
navigate('/admin/members');
} finally {
setLoading(false);
}
};
if (loading) return <div>Loading...</div>;
if (!user) return null;
return (
<>
{/* Back Button */}
<Button
variant="ghost"
onClick={() => navigate(-1)}
className="mb-6"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
{/* User Profile Header */}
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="flex items-start gap-6">
{/* Avatar */}
<div className="h-24 w-24 rounded-full bg-[#F2CC8F] flex items-center justify-center text-[#3D405B] font-semibold text-3xl">
{user.first_name?.[0]}{user.last_name?.[0]}
</div>
{/* User Info */}
<div className="flex-1">
<div className="flex items-center gap-4 mb-4">
<h1 className="text-3xl font-semibold fraunces text-[#3D405B]">
{user.first_name} {user.last_name}
</h1>
{/* Status & Role Badges */}
<Badge>{user.status}</Badge>
<Badge>{user.role}</Badge>
</div>
{/* Contact Info */}
<div className="grid md:grid-cols-2 gap-4 text-[#6B708D]">
<div className="flex items-center gap-2">
<Mail className="h-4 w-4" />
<span>{user.email}</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-4 w-4" />
<span>{user.phone}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
<span>{user.city}, {user.state} {user.zipcode}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Joined {new Date(user.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
</Card>
{/* Additional Details */}
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5]">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6">
Additional Information
</h2>
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="text-sm font-medium text-[#6B708D]">Address</label>
<p className="text-[#3D405B] mt-1">{user.address}</p>
</div>
<div>
<label className="text-sm font-medium text-[#6B708D]">Date of Birth</label>
<p className="text-[#3D405B] mt-1">
{new Date(user.date_of_birth).toLocaleDateString()}
</p>
</div>
{user.partner_first_name && (
<div>
<label className="text-sm font-medium text-[#6B708D]">Partner</label>
<p className="text-[#3D405B] mt-1">
{user.partner_first_name} {user.partner_last_name}
</p>
</div>
)}
{user.referred_by_member_name && (
<div>
<label className="text-sm font-medium text-[#6B708D]">Referred By</label>
<p className="text-[#3D405B] mt-1">{user.referred_by_member_name}</p>
</div>
)}
{user.lead_sources && user.lead_sources.length > 0 && (
<div className="md:col-span-2">
<label className="text-sm font-medium text-[#6B708D]">Lead Sources</label>
<div className="flex flex-wrap gap-2 mt-2">
{user.lead_sources.map((source, idx) => (
<Badge key={idx} variant="outline">{source}</Badge>
))}
</div>
</div>
)}
</div>
</Card>
{/* Subscription Info (if applicable) */}
{user.role === 'member' && (
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] mt-8">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6">
Subscription Information
</h2>
{/* TODO: Fetch and display subscription data */}
<p className="text-[#6B708D]">Subscription details coming soon...</p>
</Card>
)}
</>
);
};
export default AdminUserView;

View File

@@ -0,0 +1,200 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { toast } from 'sonner';
import { Users, Search, CheckCircle, Clock } from 'lucide-react';
const AdminUsers = () => {
const navigate = useNavigate();
const location = useLocation();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const tabs = [
{ name: 'All Users', path: '/admin/users' },
{ name: 'Staff', path: '/admin/staff' },
{ name: 'Members', path: '/admin/members' }
];
useEffect(() => {
fetchUsers();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, statusFilter]);
const fetchUsers = async () => {
try {
const response = await api.get('/admin/users');
setUsers(response.data);
} catch (error) {
toast.error('Failed to fetch users');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (statusFilter && statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Pending Email', className: 'bg-[#F2CC8F] text-[#3D405B]' },
pending_approval: { label: 'Pending Approval', className: 'bg-[#A3B1C6] text-white' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-[#E07A5F] text-white' },
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { label: 'Inactive', className: 'bg-[#6B708D] text-white' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
User Management
</h1>
<p className="text-lg text-[#6B708D]">
View and manage all registered users.
</p>
</div>
{/* Tab Navigation */}
<div className="border-b border-[#EAE0D5] mb-8">
<nav className="flex gap-8">
{tabs.map((tab) => (
<button
key={tab.path}
onClick={() => navigate(tab.path)}
className={`
pb-4 px-2 font-medium transition-colors relative
${location.pathname === tab.path
? 'text-[#E07A5F]'
: 'text-[#6B708D] hover:text-[#3D405B]'
}
`}
>
{tab.name}
{location.pathname === tab.path && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#E07A5F]" />
)}
</button>
))}
</nav>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#6B708D]" />
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="search-users-input"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]" data-testid="status-filter-select">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Pending Email</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Users List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading users...</p>
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-md transition-shadow"
data-testid={`user-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold fraunces text-[#3D405B]">
{user.first_name} {user.last_name}
</h3>
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#6B708D]">
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Role: <span className="capitalize">{user.role}</span></p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.referred_by_member_name && (
<p className="col-span-2">Referred by: {user.referred_by_member_name}</p>
)}
</div>
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<Users className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Users Found
</h3>
<p className="text-[#6B708D]">
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters'
: 'No users registered yet'}
</p>
</div>
)}
</>
);
};
export default AdminUsers;