theme-provider #17
@@ -2,7 +2,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "/become-a-member",
|
"homepage": "/",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/dm-sans": "^5.2.8",
|
"@fontsource/dm-sans": "^5.2.8",
|
||||||
"@fontsource/fraunces": "^5.2.9",
|
"@fontsource/fraunces": "^5.2.9",
|
||||||
|
|||||||
14
src/App.js
14
src/App.js
@@ -23,6 +23,7 @@ import AdminMembers from './pages/admin/AdminMembers';
|
|||||||
import AdminPermissions from './pages/admin/AdminPermissions';
|
import AdminPermissions from './pages/admin/AdminPermissions';
|
||||||
import AdminRoles from './pages/admin/AdminRoles';
|
import AdminRoles from './pages/admin/AdminRoles';
|
||||||
import AdminEvents from './pages/admin/AdminEvents';
|
import AdminEvents from './pages/admin/AdminEvents';
|
||||||
|
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
|
||||||
import AdminValidations from './pages/admin/AdminValidations';
|
import AdminValidations from './pages/admin/AdminValidations';
|
||||||
import AdminPlans from './pages/admin/AdminPlans';
|
import AdminPlans from './pages/admin/AdminPlans';
|
||||||
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
||||||
@@ -50,6 +51,8 @@ import Resources from './pages/Resources';
|
|||||||
import ContactUs from './pages/ContactUs';
|
import ContactUs from './pages/ContactUs';
|
||||||
import TermsOfService from './pages/TermsOfService';
|
import TermsOfService from './pages/TermsOfService';
|
||||||
import PrivacyPolicy from './pages/PrivacyPolicy';
|
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||||
|
import AcceptInvitation from './pages/AcceptInvitation';
|
||||||
|
import NotFound from './pages/NotFound';
|
||||||
|
|
||||||
const PrivateRoute = ({ children, adminOnly = false }) => {
|
const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -82,6 +85,7 @@ function App() {
|
|||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/accept-invitation" element={<AcceptInvitation />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/change-password-required" element={
|
<Route path="/change-password-required" element={
|
||||||
@@ -215,6 +219,13 @@ function App() {
|
|||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/admin/events/:eventId/attendance" element={
|
||||||
|
<PrivateRoute adminOnly>
|
||||||
|
<AdminLayout>
|
||||||
|
<AdminEventAttendance />
|
||||||
|
</AdminLayout>
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
<Route path="/admin/validations" element={
|
<Route path="/admin/validations" element={
|
||||||
<PrivateRoute adminOnly>
|
<PrivateRoute adminOnly>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@@ -278,6 +289,9 @@ function App() {
|
|||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* 404 - Catch all undefined routes */}
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
||||||
<div className="flex items-center gap-3">
|
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
|
||||||
<img
|
<img
|
||||||
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
||||||
alt="LOAF Logo"
|
alt="LOAF Logo"
|
||||||
@@ -274,11 +274,16 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
Admin
|
Admin
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
<p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
View Public Site
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
|
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import axios from 'axios';
|
|||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
const API_URL = process.env.REACT_APP_BACKEND_URL;
|
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
|
||||||
|
|
||||||
|
// Log environment on module load for debugging
|
||||||
|
console.log('[AuthContext] Module initialized with:', {
|
||||||
|
REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
|
||||||
|
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
|
||||||
|
API_URL: API_URL
|
||||||
|
});
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
@@ -54,21 +61,79 @@ export const AuthProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
const response = await axios.post(`${API_URL}/api/auth/login`, { email, password });
|
try {
|
||||||
|
console.log('[AuthContext] Starting login request...', {
|
||||||
|
API_URL: API_URL,
|
||||||
|
envBackendUrl: process.env.REACT_APP_BACKEND_URL,
|
||||||
|
fullUrl: `${API_URL}/api/auth/login`
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_URL}/api/auth/login`,
|
||||||
|
{ email, password },
|
||||||
|
{
|
||||||
|
timeout: 30000, // 30 second timeout
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[AuthContext] Login response received:', {
|
||||||
|
status: response.status,
|
||||||
|
hasToken: !!response.data?.access_token,
|
||||||
|
hasUser: !!response.data?.user
|
||||||
|
});
|
||||||
|
|
||||||
const { access_token, user: userData } = response.data;
|
const { access_token, user: userData } = response.data;
|
||||||
|
|
||||||
|
// Store token first
|
||||||
localStorage.setItem('token', access_token);
|
localStorage.setItem('token', access_token);
|
||||||
|
console.log('[AuthContext] Token stored in localStorage');
|
||||||
|
|
||||||
|
// Update state
|
||||||
setToken(access_token);
|
setToken(access_token);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
console.log('[AuthContext] User state updated:', {
|
||||||
|
email: userData.email,
|
||||||
|
role: userData.role
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch user permissions (don't let this fail the login)
|
// Fetch user permissions (don't let this fail the login)
|
||||||
|
// Use setTimeout to defer permission fetching slightly
|
||||||
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log('[AuthContext] Fetching permissions...');
|
||||||
await fetchPermissions(access_token);
|
await fetchPermissions(access_token);
|
||||||
|
console.log('[AuthContext] Permissions fetched successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch permissions during login, will retry later:', error);
|
console.error('[AuthContext] Failed to fetch permissions (non-critical):', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status
|
||||||
|
});
|
||||||
// Don't throw - permissions can be fetched later if needed
|
// Don't throw - permissions can be fetched later if needed
|
||||||
}
|
}
|
||||||
|
}, 100); // Small delay to ensure state is settled
|
||||||
|
|
||||||
return userData;
|
return userData;
|
||||||
|
} catch (error) {
|
||||||
|
// Enhanced error logging
|
||||||
|
console.error('[AuthContext] Login failed:', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status,
|
||||||
|
code: error.code,
|
||||||
|
config: {
|
||||||
|
url: error.config?.url,
|
||||||
|
method: error.config?.method,
|
||||||
|
timeout: error.config?.timeout
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-throw to let Login component handle the error
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const AcceptInvitation = () => {
|
|||||||
const [invitation, setInvitation] = useState(null);
|
const [invitation, setInvitation] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [successUser, setSuccessUser] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
password: '',
|
password: '',
|
||||||
@@ -134,19 +136,23 @@ const AcceptInvitation = () => {
|
|||||||
const { access_token, user } = response.data;
|
const { access_token, user } = response.data;
|
||||||
localStorage.setItem('token', access_token);
|
localStorage.setItem('token', access_token);
|
||||||
|
|
||||||
toast.success('Welcome to LOAF! Your account has been created successfully.');
|
|
||||||
|
|
||||||
// Call login to update auth context
|
// Call login to update auth context
|
||||||
if (login) {
|
if (login) {
|
||||||
await login(invitation.email, formData.password);
|
await login(invitation.email, formData.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect based on role
|
// Show success state
|
||||||
|
setSuccessUser(user);
|
||||||
|
setSuccess(true);
|
||||||
|
|
||||||
|
// Auto-redirect after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
if (user.role === 'admin' || user.role === 'superadmin') {
|
if (user.role === 'admin' || user.role === 'superadmin') {
|
||||||
navigate('/admin/dashboard');
|
navigate('/admin');
|
||||||
} else {
|
} else {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
|
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
@@ -206,6 +212,83 @@ const AcceptInvitation = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
const redirectPath = successUser?.role === 'admin' || successUser?.role === 'superadmin' ? '/admin' : '/dashboard';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-2xl p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||||
|
{/* Success Animation */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="h-24 w-24 mx-auto rounded-full bg-gradient-to-br from-[#81B29A] to-[#6DA085] flex items-center justify-center animate-bounce">
|
||||||
|
<CheckCircle className="h-12 w-12 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Welcome to LOAF! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Your account has been created successfully.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* User Info Card */}
|
||||||
|
<div className="mb-8 p-6 bg-gradient-to-r from-[#DDD8EB] to-[#F9F8FB] rounded-xl">
|
||||||
|
<div className="grid md:grid-cols-2 gap-4 text-left">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Name
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{successUser?.first_name} {successUser?.last_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Email
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{successUser?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Role
|
||||||
|
</p>
|
||||||
|
<div>{getRoleBadge(successUser?.role)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<Badge className="bg-[#81B29A] text-white px-4 py-2 rounded-full text-sm">
|
||||||
|
{successUser?.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Redirect Info */}
|
||||||
|
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||||
|
<p className="text-sm text-blue-800" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
<Loader2 className="h-4 w-4 inline mr-2 animate-spin" />
|
||||||
|
Redirecting you to your dashboard in 3 seconds...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Continue Button */}
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(redirectPath)}
|
||||||
|
className="w-full h-14 rounded-xl bg-gradient-to-r from-[#81B29A] to-[#6DA085] hover:from-[#6DA085] hover:to-[#5A8F72] text-white text-lg font-semibold"
|
||||||
|
>
|
||||||
|
Continue to Dashboard
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
<Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||||
|
|||||||
81
src/pages/NotFound.js
Normal file
81
src/pages/NotFound.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Card } from '../components/ui/card';
|
||||||
|
import { Home, ArrowLeft, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
const NotFound = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-2xl p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||||
|
{/* 404 Illustration */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<h1
|
||||||
|
className="text-[180px] font-bold text-transparent bg-clip-text bg-gradient-to-br from-[#ddd8eb] to-[#f9f8fb] leading-none"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Search className="h-24 w-24 text-[#664fa3] opacity-30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<h2
|
||||||
|
className="text-3xl font-semibold text-[#422268] mb-4"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Page Not Found
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-lg text-[#664fa3] mb-8 max-w-md mx-auto"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f9f8fb] px-6 py-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="rounded-xl bg-gradient-to-r from-[#664fa3] to-[#422268] hover:from-[#422268] hover:to-[#664fa3] text-white px-6 py-6"
|
||||||
|
>
|
||||||
|
<Home className="h-5 w-5 mr-2" />
|
||||||
|
Back to Home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Text */}
|
||||||
|
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
|
||||||
|
<p
|
||||||
|
className="text-sm text-[#664fa3]"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Need help? Contact us at{' '}
|
||||||
|
<a
|
||||||
|
href="mailto:support@loaftx.org"
|
||||||
|
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||||
|
>
|
||||||
|
support@loaftx.org
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFound;
|
||||||
@@ -253,7 +253,13 @@ const Plans = () => {
|
|||||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||||
</div>
|
</div>
|
||||||
) : plans.length > 0 ? (
|
) : plans.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
|
<div className={`grid gap-6 sm:gap-8 mx-auto ${
|
||||||
|
plans.length === 1
|
||||||
|
? 'grid-cols-1 max-w-md'
|
||||||
|
: plans.length === 2
|
||||||
|
? 'grid-cols-1 sm:grid-cols-2 max-w-3xl'
|
||||||
|
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl'
|
||||||
|
}`}>
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => {
|
||||||
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
||||||
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const AdminBylaws = () => {
|
|||||||
version: '',
|
version: '',
|
||||||
effective_date: '',
|
effective_date: '',
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_drive',
|
document_type: 'link',
|
||||||
is_current: false
|
is_current: false
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -71,9 +71,10 @@ const AdminBylaws = () => {
|
|||||||
version: '',
|
version: '',
|
||||||
effective_date: new Date().toISOString().split('T')[0],
|
effective_date: new Date().toISOString().split('T')[0],
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_drive',
|
document_type: 'link',
|
||||||
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
|
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
|
||||||
});
|
});
|
||||||
|
setUploadedFile(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,7 +247,7 @@ const AdminBylaws = () => {
|
|||||||
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
||||||
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
|
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>Document Type: <strong>{currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()}</strong></span>
|
<span>Document Type: <strong>{currentBylaws.document_type === 'upload' ? 'PDF Upload' : 'Link'}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -363,14 +364,16 @@ const AdminBylaws = () => {
|
|||||||
<Label htmlFor="document_type">Document Type *</Label>
|
<Label htmlFor="document_type">Document Type *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.document_type}
|
value={formData.document_type}
|
||||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
onValueChange={(value) => {
|
||||||
|
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||||
|
setUploadedFile(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
<SelectItem value="link">Link</SelectItem>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
|
||||||
<SelectItem value="upload">Upload</SelectItem>
|
<SelectItem value="upload">Upload</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -391,6 +394,11 @@ const AdminBylaws = () => {
|
|||||||
Selected: {uploadedFile.name}
|
Selected: {uploadedFile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{selectedBylaws && !uploadedFile && (
|
||||||
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
|
Current file will be kept if no new file is selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -399,12 +407,11 @@ const AdminBylaws = () => {
|
|||||||
id="document_url"
|
id="document_url"
|
||||||
value={formData.document_url}
|
value={formData.document_url}
|
||||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||||
placeholder="https://drive.google.com/file/d/..."
|
placeholder="https://docs.google.com/... or https://example.com/file.pdf"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-[#664fa3] mt-1">
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
|
||||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
548
src/pages/admin/AdminEventAttendance.js
Normal file
548
src/pages/admin/AdminEventAttendance.js
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import api from '../../utils/api';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { Badge } from '../../components/ui/badge';
|
||||||
|
import { Checkbox } from '../../components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
UserCheck,
|
||||||
|
UserX,
|
||||||
|
HelpCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
const AdminEventAttendance = () => {
|
||||||
|
const { eventId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [event, setEvent] = useState(null);
|
||||||
|
const [rsvps, setRsvps] = useState([]);
|
||||||
|
const [filteredRsvps, setFilteredRsvps] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Filters and search
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Bulk selection
|
||||||
|
const [selectedRsvps, setSelectedRsvps] = useState(new Set());
|
||||||
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEventAndRsvps();
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filterRsvps();
|
||||||
|
}, [rsvps, activeTab, searchQuery]);
|
||||||
|
|
||||||
|
const fetchEventAndRsvps = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [eventRes, rsvpsRes] = await Promise.all([
|
||||||
|
api.get(`/admin/events/${eventId}`),
|
||||||
|
api.get(`/admin/events/${eventId}/rsvps`)
|
||||||
|
]);
|
||||||
|
setEvent(eventRes.data);
|
||||||
|
setRsvps(rsvpsRes.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch event data:', error);
|
||||||
|
toast.error('Failed to load event data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterRsvps = () => {
|
||||||
|
let filtered = [...rsvps];
|
||||||
|
|
||||||
|
// Filter by RSVP status tab
|
||||||
|
if (activeTab !== 'all') {
|
||||||
|
filtered = filtered.filter(rsvp => rsvp.rsvp_status === activeTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(rsvp =>
|
||||||
|
rsvp.user_name?.toLowerCase().includes(query) ||
|
||||||
|
rsvp.user_email?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredRsvps(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectAll) {
|
||||||
|
setSelectedRsvps(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedRsvps(new Set(filteredRsvps.map(rsvp => rsvp.user_id)));
|
||||||
|
}
|
||||||
|
setSelectAll(!selectAll);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRsvp = (userId) => {
|
||||||
|
const newSelected = new Set(selectedRsvps);
|
||||||
|
if (newSelected.has(userId)) {
|
||||||
|
newSelected.delete(userId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(userId);
|
||||||
|
}
|
||||||
|
setSelectedRsvps(newSelected);
|
||||||
|
setSelectAll(newSelected.size === filteredRsvps.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAttendance = async (attended) => {
|
||||||
|
if (selectedRsvps.size === 0) {
|
||||||
|
toast.error('Please select at least one RSVP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const updates = Array.from(selectedRsvps).map(userId => ({
|
||||||
|
user_id: userId,
|
||||||
|
attended
|
||||||
|
}));
|
||||||
|
|
||||||
|
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
||||||
|
|
||||||
|
toast.success(`Marked ${selectedRsvps.size} ${selectedRsvps.size === 1 ? 'person' : 'people'} as ${attended ? 'attended' : 'not attended'}`);
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
await fetchEventAndRsvps();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
setSelectedRsvps(new Set());
|
||||||
|
setSelectAll(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update attendance:', error);
|
||||||
|
toast.error('Failed to update attendance');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIndividualAttendance = async (userId, attended) => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const updates = [{
|
||||||
|
user_id: userId,
|
||||||
|
attended
|
||||||
|
}];
|
||||||
|
|
||||||
|
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
||||||
|
|
||||||
|
toast.success(`Attendance ${attended ? 'confirmed' : 'removed'}`);
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
await fetchEventAndRsvps();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update attendance:', error);
|
||||||
|
toast.error('Failed to update attendance');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToCSV = () => {
|
||||||
|
if (filteredRsvps.length === 0) {
|
||||||
|
toast.error('No RSVPs to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV header
|
||||||
|
const headers = ['Name', 'Email', 'RSVP Status', 'Attended', 'Attended At'];
|
||||||
|
|
||||||
|
// CSV rows
|
||||||
|
const rows = filteredRsvps.map(rsvp => [
|
||||||
|
`"${rsvp.user_name}"`,
|
||||||
|
`"${rsvp.user_email}"`,
|
||||||
|
`"${rsvp.rsvp_status.toUpperCase()}"`,
|
||||||
|
rsvp.attended ? 'Yes' : 'No',
|
||||||
|
rsvp.attended_at ? `"${moment(rsvp.attended_at).format('YYYY-MM-DD HH:mm A')}"` : ''
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Combine headers and rows
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Create blob and download
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `${event?.title.replace(/\s+/g, '_')}_RSVPs_${moment().format('YYYY-MM-DD')}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
toast.success('CSV exported successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStats = () => {
|
||||||
|
const total = rsvps.length;
|
||||||
|
const yesCount = rsvps.filter(r => r.rsvp_status === 'yes').length;
|
||||||
|
const noCount = rsvps.filter(r => r.rsvp_status === 'no').length;
|
||||||
|
const maybeCount = rsvps.filter(r => r.rsvp_status === 'maybe').length;
|
||||||
|
const attendedCount = rsvps.filter(r => r.attended).length;
|
||||||
|
|
||||||
|
return { total, yesCount, noCount, maybeCount, attendedCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = getStats();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-[#664fa3]">Loading event data...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-[#664fa3] mb-4">Event not found</p>
|
||||||
|
<Button onClick={() => navigate('/admin/events')} className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl">
|
||||||
|
Back to Events
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/admin/events')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back to Events
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Event Attendance
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Manage RSVPs and track attendance for this event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={exportToCSV}
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export to CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Details Card */}
|
||||||
|
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{event.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-4 text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>{moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')}</span>
|
||||||
|
</div>
|
||||||
|
{event.location && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
<span>{event.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={`${event.published ? 'bg-[#81B29A]' : 'bg-[#ddd8eb]'} text-white px-3 py-1`}>
|
||||||
|
{event.published ? 'Published' : 'Draft'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="h-8 w-8 text-[#664fa3]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserCheck className="h-8 w-8 text-[#81B29A]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserX className="h-8 w-8 text-[#E07A5F]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<HelpCircle className="h-8 w-8 text-[#F2CC8F]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Check className="h-8 w-8 text-[#664fa3]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Actions */}
|
||||||
|
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tab Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
variant={activeTab === 'all' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'bg-[#664fa3] hover:bg-[#422268] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
All ({stats.total})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('yes')}
|
||||||
|
variant={activeTab === 'yes' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'yes'
|
||||||
|
? 'bg-[#81B29A] hover:bg-[#6a9a83] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Yes ({stats.yesCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('no')}
|
||||||
|
variant={activeTab === 'no' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'no'
|
||||||
|
? 'bg-[#E07A5F] hover:bg-[#d16b54] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
No ({stats.noCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('maybe')}
|
||||||
|
variant={activeTab === 'maybe' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'maybe'
|
||||||
|
? 'bg-[#F2CC8F] hover:bg-[#e8bf7a] text-[#422268]'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Maybe ({stats.maybeCount})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Bulk Actions */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-[200px] max-w-md relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#664fa3]" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-[#ddd8eb] rounded-xl"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRsvps.size > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge className="bg-[#664fa3] text-white px-3 py-1">
|
||||||
|
{selectedRsvps.size} selected
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleBulkAttendance(true)}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
Mark Attended
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleBulkAttendance(false)}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#E07A5F] hover:bg-[#d16b54] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Mark Not Attended
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RSVP Table */}
|
||||||
|
<Card className="bg-white border-[#ddd8eb] rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#F8F7FB] border-b border-[#ddd8eb]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectAll}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
RSVP Status
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-center text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Attendance
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Attended At
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredRsvps.length > 0 ? (
|
||||||
|
filteredRsvps.map((rsvp) => (
|
||||||
|
<tr key={rsvp.user_id} className="border-b border-[#ddd8eb] hover:bg-[#F8F7FB] transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRsvps.has(rsvp.user_id)}
|
||||||
|
onCheckedChange={() => handleSelectRsvp(rsvp.user_id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.user_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.user_email}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge
|
||||||
|
className={`${
|
||||||
|
rsvp.rsvp_status === 'yes'
|
||||||
|
? 'bg-[#81B29A]'
|
||||||
|
: rsvp.rsvp_status === 'no'
|
||||||
|
? 'bg-[#E07A5F]'
|
||||||
|
: 'bg-[#F2CC8F] text-[#422268]'
|
||||||
|
} text-white text-xs px-2 py-1`}
|
||||||
|
>
|
||||||
|
{rsvp.rsvp_status.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{rsvp.attended ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleIndividualAttendance(rsvp.user_id, false)}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-lg min-w-[120px]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Attended
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleIndividualAttendance(rsvp.user_id, true)}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#81B29A] hover:text-white hover:border-[#81B29A] rounded-lg min-w-[120px]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Not Attended
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="6" className="px-4 py-12 text-center">
|
||||||
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminEventAttendance;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
@@ -8,16 +9,14 @@ import { Input } from '../../components/ui/input';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
|
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||||
import { AttendanceDialog } from '../../components/AttendanceDialog';
|
|
||||||
|
|
||||||
const AdminEvents = () => {
|
const AdminEvents = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState(null);
|
const [editingEvent, setEditingEvent] = useState(null);
|
||||||
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -342,19 +341,16 @@ const AdminEvents = () => {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
|
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
|
||||||
{/* Mark Attendance Button */}
|
{/* Manage Attendance Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
|
||||||
setSelectedEvent(event);
|
|
||||||
setAttendanceDialogOpen(true);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
||||||
data-testid={`mark-attendance-${event.id}`}
|
data-testid={`mark-attendance-${event.id}`}
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4 mr-2" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
Mark Attendance ({event.rsvp_count || 0} RSVPs)
|
Manage Attendance ({event.rsvp_count || 0} RSVPs)
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Other Actions */}
|
{/* Other Actions */}
|
||||||
@@ -419,14 +415,6 @@ const AdminEvents = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attendance Dialog */}
|
|
||||||
<AttendanceDialog
|
|
||||||
event={selectedEvent}
|
|
||||||
open={attendanceDialogOpen}
|
|
||||||
onOpenChange={setAttendanceDialogOpen}
|
|
||||||
onSuccess={fetchEvents}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const AdminFinancials = () => {
|
|||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
title: '',
|
title: '',
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_drive'
|
document_type: 'link'
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -67,8 +67,9 @@ const AdminFinancials = () => {
|
|||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
title: '',
|
title: '',
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_drive'
|
document_type: 'link'
|
||||||
});
|
});
|
||||||
|
setUploadedFile(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,14 +275,16 @@ const AdminFinancials = () => {
|
|||||||
<Label htmlFor="document_type">Document Type *</Label>
|
<Label htmlFor="document_type">Document Type *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.document_type}
|
value={formData.document_type}
|
||||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
onValueChange={(value) => {
|
||||||
|
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||||
|
setUploadedFile(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
<SelectItem value="link">Link</SelectItem>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
|
||||||
<SelectItem value="upload">Upload</SelectItem>
|
<SelectItem value="upload">Upload</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -302,6 +305,11 @@ const AdminFinancials = () => {
|
|||||||
Selected: {uploadedFile.name}
|
Selected: {uploadedFile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{selectedReport && !uploadedFile && (
|
||||||
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
|
Current file will be kept if no new file is selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -310,12 +318,11 @@ const AdminFinancials = () => {
|
|||||||
id="document_url"
|
id="document_url"
|
||||||
value={formData.document_url}
|
value={formData.document_url}
|
||||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||||
placeholder="https://drive.google.com/file/d/..."
|
placeholder="https://docs.google.com/... or https://example.com/file.pdf"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-[#664fa3] mt-1">
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
|
||||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '../../components/ui/select';
|
} from '../../components/ui/select';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
||||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react';
|
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin, AlertCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
@@ -179,6 +180,32 @@ const AdminGallery = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State Message */}
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="mt-4 p-4 bg-[#f1eef9] border-2 border-[#DDD8EB] rounded-xl">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
No Events Available
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
You need to create an event before uploading gallery images. Events help organize photos by occasion.
|
||||||
|
</p>
|
||||||
|
<Link to="/admin/events">
|
||||||
|
<Button
|
||||||
|
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl text-sm"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
Create Your First Event
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedEvent && (
|
{selectedEvent && (
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const AdminNewsletters = () => {
|
|||||||
description: '',
|
description: '',
|
||||||
published_date: '',
|
published_date: '',
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_docs'
|
document_type: 'link'
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -70,8 +70,9 @@ const AdminNewsletters = () => {
|
|||||||
description: '',
|
description: '',
|
||||||
published_date: new Date().toISOString().split('T')[0],
|
published_date: new Date().toISOString().split('T')[0],
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_docs'
|
document_type: 'link'
|
||||||
});
|
});
|
||||||
|
setUploadedFile(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,7 +233,7 @@ const AdminNewsletters = () => {
|
|||||||
{formatDate(newsletter.published_date)}
|
{formatDate(newsletter.published_date)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
||||||
{newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
|
{newsletter.document_type === 'upload' ? 'PDF Upload' : 'Link'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -322,14 +323,16 @@ const AdminNewsletters = () => {
|
|||||||
<Label htmlFor="document_type">Document Type *</Label>
|
<Label htmlFor="document_type">Document Type *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.document_type}
|
value={formData.document_type}
|
||||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
onValueChange={(value) => {
|
||||||
|
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||||
|
setUploadedFile(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="google_docs">Google Docs</SelectItem>
|
<SelectItem value="link">Link</SelectItem>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
|
||||||
<SelectItem value="upload">Upload</SelectItem>
|
<SelectItem value="upload">Upload</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -350,6 +353,11 @@ const AdminNewsletters = () => {
|
|||||||
Selected: {uploadedFile.name}
|
Selected: {uploadedFile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{selectedNewsletter && !uploadedFile && (
|
||||||
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
|
Current file will be kept if no new file is selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -358,12 +366,11 @@ const AdminNewsletters = () => {
|
|||||||
id="document_url"
|
id="document_url"
|
||||||
value={formData.document_url}
|
value={formData.document_url}
|
||||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||||
placeholder="https://docs.google.com/document/d/..."
|
placeholder="https://docs.google.com/document/d/... or https://example.com/file.pdf"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-[#664fa3] mt-1">
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
{formData.document_type === 'google_docs' && 'Paste the shareable link to your Google Doc'}
|
Paste the shareable link to your document (Google Docs, Dropbox, PDF URL, etc.)
|
||||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import CreateStaffDialog from '../../components/CreateStaffDialog';
|
|||||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||||
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
|
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye } from 'lucide-react';
|
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||||
|
|
||||||
const AdminStaff = () => {
|
const AdminStaff = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -71,6 +71,32 @@ const AdminStaff = () => {
|
|||||||
setFilteredUsers(filtered);
|
setFilteredUsers(filtered);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (userId, currentStatus) => {
|
||||||
|
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
||||||
|
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
|
||||||
|
fetchStaff(); // Refresh list
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to update user status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId, userName) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete ${userName}? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/users/${userId}`);
|
||||||
|
toast.success('User deleted successfully');
|
||||||
|
fetchStaff(); // Refresh list
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getRoleBadge = (role) => {
|
const getRoleBadge = (role) => {
|
||||||
const config = {
|
const config = {
|
||||||
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
||||||
@@ -250,7 +276,7 @@ const AdminStaff = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -259,6 +285,41 @@ const AdminStaff = () => {
|
|||||||
<Edit className="h-4 w-4 mr-2" />
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{hasPermission('users.status') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleToggleStatus(user.id, user.status)}
|
||||||
|
variant="outline"
|
||||||
|
className={`border-2 rounded-full px-4 py-2 ${
|
||||||
|
user.status === 'active'
|
||||||
|
? 'border-orange-500 text-orange-600 hover:bg-orange-50'
|
||||||
|
: 'border-green-500 text-green-600 hover:bg-green-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.status === 'active' ? (
|
||||||
|
<>
|
||||||
|
<UserX className="h-4 w-4 mr-2" />
|
||||||
|
Deactivate
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="h-4 w-4 mr-2" />
|
||||||
|
Activate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPermission('users.delete') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 border-red-500 text-red-600 hover:bg-red-50 rounded-full px-4 py-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
93
src/pages/members/MemberCalendar.css
Normal file
93
src/pages/members/MemberCalendar.css
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/* Member Calendar Custom Styles */
|
||||||
|
|
||||||
|
.member-calendar .rbc-header {
|
||||||
|
padding: 12px 6px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #422268;
|
||||||
|
background-color: #f9f7fc;
|
||||||
|
border-bottom: 2px solid #ddd8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-today {
|
||||||
|
background-color: #f1eef9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-off-range-bg {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-event {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-event:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button {
|
||||||
|
color: #664fa3;
|
||||||
|
border-color: #ddd8eb;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button:hover {
|
||||||
|
background-color: #f1eef9;
|
||||||
|
border-color: #664fa3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button:active,
|
||||||
|
.member-calendar .rbc-toolbar button.rbc-active {
|
||||||
|
background-color: #664fa3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-month-view {
|
||||||
|
border: 1px solid #ddd8eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-day-bg {
|
||||||
|
border-color: #ddd8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-date-cell {
|
||||||
|
padding: 8px;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure toolbar buttons are clickable */
|
||||||
|
.member-calendar .rbc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button {
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid #ddd8eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
|
|||||||
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||||
|
import './MemberCalendar.css';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import Navbar from '../../components/Navbar';
|
import Navbar from '../../components/Navbar';
|
||||||
import MemberFooter from '../../components/MemberFooter';
|
import MemberFooter from '../../components/MemberFooter';
|
||||||
@@ -26,6 +27,8 @@ export default function MemberCalendar() {
|
|||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [rsvpLoading, setRsvpLoading] = useState(false);
|
const [rsvpLoading, setRsvpLoading] = useState(false);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [currentView, setCurrentView] = useState('month');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
@@ -58,6 +61,14 @@ export default function MemberCalendar() {
|
|||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (newDate) => {
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewChange = (newView) => {
|
||||||
|
setCurrentView(newView);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRSVP = async (status) => {
|
const handleRSVP = async (status) => {
|
||||||
if (!selectedEvent) return;
|
if (!selectedEvent) return;
|
||||||
|
|
||||||
@@ -171,10 +182,13 @@ export default function MemberCalendar() {
|
|||||||
startAccessor="start"
|
startAccessor="start"
|
||||||
endAccessor="end"
|
endAccessor="end"
|
||||||
style={{ height: 700 }}
|
style={{ height: 700 }}
|
||||||
|
date={currentDate}
|
||||||
|
view={currentView}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onView={handleViewChange}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
eventPropGetter={eventStyleGetter}
|
eventPropGetter={eventStyleGetter}
|
||||||
views={['month', 'week', 'day', 'agenda']}
|
views={['month', 'week', 'day', 'agenda']}
|
||||||
defaultView="month"
|
|
||||||
popup
|
popup
|
||||||
className="member-calendar"
|
className="member-calendar"
|
||||||
/>
|
/>
|
||||||
@@ -320,64 +334,6 @@ export default function MemberCalendar() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx global>{`
|
|
||||||
.member-calendar .rbc-header {
|
|
||||||
padding: 12px 6px;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #422268;
|
|
||||||
background-color: #f9f7fc;
|
|
||||||
border-bottom: 2px solid #ddd8eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-today {
|
|
||||||
background-color: #f1eef9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-off-range-bg {
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-event {
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-event:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button {
|
|
||||||
color: #664fa3;
|
|
||||||
border-color: #ddd8eb;
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button:hover {
|
|
||||||
background-color: #f1eef9;
|
|
||||||
border-color: #664fa3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button.rbc-active {
|
|
||||||
background-color: #664fa3;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-month-view {
|
|
||||||
border: 1px solid #ddd8eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-day-bg {
|
|
||||||
border-color: #ddd8eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-date-cell {
|
|
||||||
padding: 8px;
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<MemberFooter />
|
<MemberFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,14 +4,60 @@ const API_URL = process.env.REACT_APP_BACKEND_URL;
|
|||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: `${API_URL}/api`,
|
baseURL: `${API_URL}/api`,
|
||||||
|
timeout: 30000, // 30 second timeout for all requests
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
// Request interceptor - add auth token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('[API] Request error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle errors and retries
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
async (error) => {
|
||||||
|
const config = error.config;
|
||||||
|
|
||||||
|
// Don't retry if we've already retried or if it's a client error (4xx)
|
||||||
|
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
|
||||||
|
console.error('[API] Request failed:', {
|
||||||
|
url: config?.url,
|
||||||
|
method: config?.method,
|
||||||
|
status: error.response?.status,
|
||||||
|
message: error.message,
|
||||||
|
data: error.response?.data
|
||||||
|
});
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as retry to prevent infinite loops
|
||||||
|
config.__isRetry = true;
|
||||||
|
|
||||||
|
// Retry after 1 second for server errors or network issues
|
||||||
|
console.warn('[API] Retrying request after 1s:', {
|
||||||
|
url: config.url,
|
||||||
|
method: config.method,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(api.request(config));
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user