forked from andika/membership-fe
Initial Commit
This commit is contained in:
230
src/pages/Dashboard.js
Normal file
230
src/pages/Dashboard.js
Normal 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
201
src/pages/EventDetails.js
Normal 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
132
src/pages/Events.js
Normal 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
140
src/pages/Landing.js
Normal 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
122
src/pages/Login.js
Normal 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
116
src/pages/PaymentCancel.js
Normal 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
135
src/pages/PaymentSuccess.js
Normal 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
196
src/pages/Plans.js
Normal 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
230
src/pages/Profile.js
Normal 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
388
src/pages/Register.js
Normal 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
105
src/pages/VerifyEmail.js
Normal 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;
|
||||
469
src/pages/admin/AdminApprovals.js
Normal file
469
src/pages/admin/AdminApprovals.js
Normal 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;
|
||||
132
src/pages/admin/AdminDashboard.js
Normal file
132
src/pages/admin/AdminDashboard.js
Normal 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;
|
||||
430
src/pages/admin/AdminEvents.js
Normal file
430
src/pages/admin/AdminEvents.js
Normal 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;
|
||||
294
src/pages/admin/AdminMembers.js
Normal file
294
src/pages/admin/AdminMembers.js
Normal 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;
|
||||
365
src/pages/admin/AdminPlans.js
Normal file
365
src/pages/admin/AdminPlans.js
Normal 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;
|
||||
223
src/pages/admin/AdminStaff.js
Normal file
223
src/pages/admin/AdminStaff.js
Normal 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;
|
||||
151
src/pages/admin/AdminUserView.js
Normal file
151
src/pages/admin/AdminUserView.js
Normal 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;
|
||||
200
src/pages/admin/AdminUsers.js
Normal file
200
src/pages/admin/AdminUsers.js
Normal 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;
|
||||
Reference in New Issue
Block a user