forked from andika/membership-fe
Compare commits
87 Commits
96628b94b4
...
dav-prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 48c5a916d9 | |||
|
|
002ef5c897 | ||
| 7d0c207f1b | |||
|
|
8ea486a4f4 | ||
|
|
264ee860df | ||
|
|
65c3e3b92d | ||
|
|
819062d697 | ||
|
|
c73ebfb6c0 | ||
|
|
3822ba8ffb | ||
|
|
c79db66739 | ||
|
|
57cd18ad9d | ||
| 56dd9eeb77 | |||
|
|
e831835e6d | ||
|
|
9287adec01 | ||
|
|
0c1202d89a | ||
|
|
0ebfe71361 | ||
|
|
a935c0f4dd | ||
|
|
4ccaca192d | ||
|
|
4cdccc0323 | ||
|
|
21a269998d | ||
|
|
e04d39fe17 | ||
|
|
30d32d8823 | ||
|
|
9c2d516f9d | ||
|
|
7694532d53 | ||
| 1f9e6ea191 | |||
|
|
ee0ad176b0 | ||
|
|
a93e2aa863 | ||
| 66c2bedbed | |||
|
|
180eb1ce85 | ||
|
|
5377a0f465 | ||
|
|
c54eb23689 | ||
| 9f7367ceeb | |||
|
|
f71931d4a7 | ||
|
|
97cc5bdedf | ||
|
|
8011913c4d | ||
| d94ea7b6d5 | |||
|
|
40a0e3f342 | ||
|
|
968eaccac2 | ||
|
|
11de3d1eed | ||
|
|
11142ec50e | ||
| 24519a7080 | |||
|
|
0249cad261 | ||
| b1b9a05d4f | |||
|
|
56711e9136 | ||
|
|
03b76a8e58 | ||
|
|
1acb13ba79 | ||
|
|
fa9a1d1d1d | ||
|
|
48802fe0c6 | ||
| a2070b4e4e | |||
|
|
8c351773ba | ||
| 6a21d32319 | |||
|
|
3511e7a9c8 | ||
|
|
33a4d8f4c4 | ||
|
|
1d70ac4ec7 | ||
|
|
a6c2475092 | ||
|
|
6d777ed583 | ||
|
|
99d65c917f | ||
|
|
0f16264656 | ||
|
|
33fc3a101d | ||
|
|
4093c1603e | ||
| 035cc896df | |||
|
|
8ffa97bcd1 | ||
|
|
b6d25cdab7 | ||
|
|
f3610282f2 | ||
|
|
f1dd7fe75b | ||
|
|
37ccfe7767 | ||
|
|
93cd4c1316 | ||
|
|
a6656b1ff0 | ||
|
|
1d4ed96dc9 | ||
|
|
a9bdd1d0a6 | ||
| 4848ec3942 | |||
| 41d2466cbf | |||
|
|
8c0d9a2a18 | ||
|
|
f7fef8572a | ||
|
|
23163a7a2b | ||
|
|
4b0517b92c | ||
|
|
bebbba1ece | ||
|
|
5a46375212 | ||
|
|
d683ec6b5b | ||
|
|
03eb349f0e | ||
|
|
b842130b62 | ||
|
|
eee26cf108 | ||
|
|
9ed778db1c | ||
|
|
ac850d65d3 | ||
|
|
40a8930b93 | ||
|
|
4d80f9aca5 | ||
|
|
02e38e1050 |
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||
REACT_APP_BASENAME=/membership
|
||||
PUBLIC_URL=/membership
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
WDS_SOCKET_PORT=443
|
||||
|
||||
# Backend API URL
|
||||
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||
|
||||
# App Base Path Configuration
|
||||
# Examples:
|
||||
# - For root path: REACT_APP_BASENAME=
|
||||
# - For subpath: REACT_APP_BASENAME=/membership
|
||||
# - For production: REACT_APP_BASENAME=/membership
|
||||
REACT_APP_BASENAME=
|
||||
|
||||
# Feature Flags
|
||||
REACT_APP_ENABLE_VISUAL_EDITS=false
|
||||
ENABLE_HEALTH_CHECK=false
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "/membership",
|
||||
"homepage": "/",
|
||||
"dependencies": {
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/fraunces": "^5.2.9",
|
||||
@@ -53,9 +53,11 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.1",
|
||||
"react-router-dom": "^7.5.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -83,6 +85,7 @@
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@eslint/js": "9.23.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "9.23.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
|
||||
BIN
public/friendships.png
Normal file
BIN
public/friendships.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
5
public/health.json
Normal file
5
public/health.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"status": "healthy",
|
||||
"mode": "production",
|
||||
"build": "optimized"
|
||||
}
|
||||
@@ -25,7 +25,6 @@
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>LOAF - Lesbians Over Age Fifty</title>
|
||||
<script src="#"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
BIN
public/shooting_star_2.png
Normal file
BIN
public/shooting_star_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/web_elements_tagline.png
Normal file
BIN
public/web_elements_tagline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
32
src/App.css
32
src/App.css
@@ -1,32 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', sans-serif;
|
||||
background-color: #FFFFFF;
|
||||
color: #422268;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inter {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.nunito-sans {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
|
||||
.bg-purple-gradient {
|
||||
background: linear-gradient(135deg, rgba(100, 76, 159, 0.2) 0%, rgba(72, 40, 110, 0.2) 100%);
|
||||
}
|
||||
|
||||
.bg-soft-mesh {
|
||||
background: radial-gradient(ellipse at top right, rgba(221, 216, 235, 0.4) 0%, #FFFFFF 50%, #FFFFFF 100%);
|
||||
}
|
||||
69
src/App.js
69
src/App.js
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import IdleSessionWarning from './components/IdleSessionWarning';
|
||||
import Landing from './pages/Landing';
|
||||
import Register from './pages/Register';
|
||||
import Login from './pages/Login';
|
||||
@@ -17,14 +18,18 @@ import BecomeMember from './pages/BecomeMember';
|
||||
import PaymentSuccess from './pages/PaymentSuccess';
|
||||
import PaymentCancel from './pages/PaymentCancel';
|
||||
import AdminDashboard from './pages/admin/AdminDashboard';
|
||||
import AdminUsers from './pages/admin/AdminUsers';
|
||||
import AdminUserView from './pages/admin/AdminUserView';
|
||||
import AdminStaff from './pages/admin/AdminStaff';
|
||||
import AdminMembers from './pages/admin/AdminMembers';
|
||||
import AdminPermissions from './pages/admin/AdminPermissions';
|
||||
import AdminSettings from './pages/admin/AdminSettings';
|
||||
import AdminRoles from './pages/admin/AdminRoles';
|
||||
import AdminEvents from './pages/admin/AdminEvents';
|
||||
import AdminApprovals from './pages/admin/AdminApprovals';
|
||||
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
|
||||
import AdminValidations from './pages/admin/AdminValidations';
|
||||
import AdminPlans from './pages/admin/AdminPlans';
|
||||
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
||||
import AdminDonations from './pages/admin/AdminDonations';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import MemberRoute from './components/MemberRoute';
|
||||
@@ -46,6 +51,10 @@ import Donate from './pages/Donate';
|
||||
import DonationSuccess from './pages/DonationSuccess';
|
||||
import Resources from './pages/Resources';
|
||||
import ContactUs from './pages/ContactUs';
|
||||
import TermsOfService from './pages/TermsOfService';
|
||||
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||
import AcceptInvitation from './pages/AcceptInvitation';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -58,7 +67,7 @@ const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
if (adminOnly && user.role !== 'admin') {
|
||||
if (adminOnly && !['admin', 'superadmin'].includes(user.role)) {
|
||||
return <Navigate to="/dashboard" />;
|
||||
}
|
||||
|
||||
@@ -66,14 +75,19 @@ const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
// Read basename from environment variable (defaults to empty string for root path)
|
||||
// Set REACT_APP_BASENAME in .env to use a subpath (e.g., "/membership")
|
||||
const basename = process.env.REACT_APP_BASENAME || '';
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter basename="/membership">
|
||||
<BrowserRouter basename={basename}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/accept-invitation" element={<AcceptInvitation />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/change-password-required" element={
|
||||
@@ -105,6 +119,10 @@ function App() {
|
||||
<Route path="/donate" element={<Donate />} />
|
||||
<Route path="/donation-success" element={<DonationSuccess />} />
|
||||
|
||||
{/* Legal Pages - Public Access */}
|
||||
<Route path="/terms-of-service" element={<TermsOfService />} />
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||
|
||||
<Route path="/dashboard" element={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
@@ -189,13 +207,6 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/users" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminUsers />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/users/:userId" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
@@ -210,10 +221,17 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/approvals" element={
|
||||
<Route path="/admin/events/:eventId/attendance" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminApprovals />
|
||||
<AdminEventAttendance />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/validations" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminValidations />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
@@ -231,6 +249,13 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/donations" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminDonations />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/gallery" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
@@ -259,8 +284,26 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/permissions" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminRoles />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/settings" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminSettings />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
|
||||
{/* 404 - Catch all undefined routes */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Toaster position="top-right" />
|
||||
<IdleSessionWarning />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function AddToCalendarButton({
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={variant} size={size} className="gap-2">
|
||||
<Button variant={variant} size={size} className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full gap-2 dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Add to Calendar
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
@@ -128,7 +128,7 @@ export default function AddToCalendarButton({
|
||||
{event && (
|
||||
<>
|
||||
{/* Single Event Export Options */}
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-[#422268]">
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-[var(--purple-ink)]">
|
||||
Add This Event
|
||||
</div>
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function AddToCalendarButton({
|
||||
{showSubscribe && (
|
||||
<>
|
||||
{/* Subscription Options */}
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-[#422268]">
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-[var(--purple-ink)]">
|
||||
Calendar Feeds
|
||||
</div>
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function AddToCalendarButton({
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Subscribe to My Events
|
||||
<div className="text-xs text-[#664fa3] mt-0.5">
|
||||
<div className="text-xs text-brand-purple mt-0.5">
|
||||
Auto-syncs your RSVP'd events
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@@ -198,7 +198,7 @@ export default function AddToCalendarButton({
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download All Events
|
||||
<div className="text-xs text-[#664fa3] mt-0.5">
|
||||
<div className="text-xs text-brand-purple mt-0.5">
|
||||
One-time import of all upcoming events
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@@ -206,7 +206,7 @@ export default function AddToCalendarButton({
|
||||
)}
|
||||
|
||||
{!event && !showSubscribe && (
|
||||
<div className="px-2 py-6 text-center text-sm text-[#664fa3]">
|
||||
<div className="px-2 py-6 text-center text-sm text-brand-purple ">
|
||||
No event selected
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../utils/api';
|
||||
import { Badge } from './ui/badge';
|
||||
@@ -21,17 +22,22 @@ import {
|
||||
DollarSign,
|
||||
Scale,
|
||||
HardDrive,
|
||||
Repeat
|
||||
Repeat,
|
||||
Heart,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [storageUsed, setStorageUsed] = useState(0);
|
||||
const [storageLimit, setStorageLimit] = useState(0);
|
||||
const [storagePercentage, setStoragePercentage] = useState(0);
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Fetch pending approvals count
|
||||
useEffect(() => {
|
||||
@@ -39,7 +45,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
const pending = response.data.filter(u =>
|
||||
['pending_approval', 'pre_approved'].includes(u.status)
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
|
||||
);
|
||||
setPendingCount(pending.length);
|
||||
} catch (error) {
|
||||
@@ -85,6 +91,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
setTheme(isDark ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
@@ -105,9 +115,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Approvals',
|
||||
name: 'Validations',
|
||||
icon: CheckCircle,
|
||||
path: '/admin/approvals',
|
||||
path: '/admin/validations',
|
||||
disabled: false,
|
||||
badge: pendingCount
|
||||
},
|
||||
@@ -123,6 +133,12 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
path: '/admin/subscriptions',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Donations',
|
||||
icon: Heart,
|
||||
path: '/admin/donations',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
icon: Calendar,
|
||||
@@ -154,9 +170,16 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Roles',
|
||||
name: 'Permissions',
|
||||
icon: Shield,
|
||||
path: '/admin/roles',
|
||||
path: '/admin/permissions',
|
||||
disabled: false,
|
||||
superadminOnly: true
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
path: '/admin/settings',
|
||||
disabled: false,
|
||||
superadminOnly: true
|
||||
}
|
||||
@@ -165,11 +188,15 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
// Filter nav items based on user role
|
||||
const filteredNavItems = navItems.filter(item => {
|
||||
if (item.superadminOnly && user?.role !== 'superadmin') {
|
||||
console.log('Filtering out superadmin-only item:', item.name, 'User role:', user?.role);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Debug: Log filtered items count
|
||||
console.log('Total nav items:', navItems.length, 'Filtered items:', filteredNavItems.length, 'User role:', user?.role);
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === '/admin') {
|
||||
return location.pathname === '/admin';
|
||||
@@ -177,43 +204,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
bg-white border-r border-[#ddd8eb] transition-all duration-300 ease-out
|
||||
${isMobile ? 'fixed inset-y-0 left-0 z-40' : 'relative'}
|
||||
${isOpen ? 'w-64' : 'w-16'}
|
||||
${isMobile && !isOpen ? '-translate-x-full' : 'translate-x-0'}
|
||||
flex flex-col
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
||||
{isOpen && (
|
||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Admin
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors ml-auto"
|
||||
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Menu className="h-5 w-5 text-[#422268]" />
|
||||
) : isOpen ? (
|
||||
<ChevronLeft className="h-5 w-5 text-[#422268]" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-[#422268]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
const renderNavItem = (item) => {
|
||||
if (!item) return null;
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{filteredNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
|
||||
@@ -229,16 +222,16 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg transition-all relative
|
||||
${item.disabled
|
||||
? 'opacity-50 cursor-not-allowed text-[#664fa3]'
|
||||
? 'opacity-50 cursor-not-allowed text-brand-purple '
|
||||
: active
|
||||
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
|
||||
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
|
||||
? 'bg-[var(--orange-light)]/10 text-[var(--purple-ink)]'
|
||||
: 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Active border */}
|
||||
{active && !item.disabled && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#ff9e77] rounded-r" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent rounded-r" />
|
||||
)}
|
||||
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
@@ -247,12 +240,12 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
<>
|
||||
<span className="flex-1">{item.name}</span>
|
||||
{item.disabled && (
|
||||
<Badge className="bg-[#DDD8EB] text-[#422268] text-xs px-2 py-0.5">
|
||||
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)] text-xs px-2 py-0.5">
|
||||
Soon
|
||||
</Badge>
|
||||
)}
|
||||
{item.badge > 0 && !item.disabled && (
|
||||
<Badge className="bg-[#ff9e77] text-white text-xs px-2 py-0.5">
|
||||
<Badge className="bg-accent foreground text-xs px-2 py-0.5">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -261,7 +254,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
|
||||
{/* Badge when collapsed */}
|
||||
{!isOpen && item.badge > 0 && !item.disabled && (
|
||||
<div className="absolute -top-1 -right-1 bg-[#ff9e77] text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
||||
<div className="absolute -top-1 -right-1 bg-accent text-white foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
||||
{item.badge}
|
||||
</div>
|
||||
)}
|
||||
@@ -269,71 +262,219 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
|
||||
{/* Tooltip when collapsed */}
|
||||
{!isOpen && (
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
{item.name}
|
||||
{item.badge > 0 && ` (${item.badge})`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
bg-background border-r border-[var(--neutral-800)] transition-all duration-300 ease-out
|
||||
${isMobile ? 'fixed inset-y-0 left-0 z-40' : 'relative'}
|
||||
${isOpen ? 'w-64' : 'w-16'}
|
||||
${isMobile && !isOpen ? '-translate-x-full' : 'translate-x-0'}
|
||||
flex flex-col
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]">
|
||||
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
|
||||
<img
|
||||
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
||||
alt="LOAF Logo"
|
||||
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
||||
}`}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-semibold text-primary dark:text-brand-light-lavender " style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Admin
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-lg hover:bg-[var(--neutral-800)]/20 transition-colors"
|
||||
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Menu className="h-5 w-5 text-primary" />
|
||||
) : isOpen ? (
|
||||
<ChevronLeft className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto p-4 scrollbar-dashboard scrollbar-x-dashboard">
|
||||
{/* Dashboard - Standalone */}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
||||
|
||||
{/* MEMBERSHIP Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Membership
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Members'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
|
||||
</div>
|
||||
|
||||
{/* FINANCIALS Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Financials
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Plans'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Subscriptions'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Donations'))}
|
||||
</div>
|
||||
|
||||
{/* EVENTS & MEDIA Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Events & Media
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Events'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Gallery'))}
|
||||
</div>
|
||||
|
||||
{/* DOCUMENTATION Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Documentation
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Newsletters'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Financials'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Bylaws'))}
|
||||
</div>
|
||||
|
||||
{/* SYSTEM Section - Superadmin only */}
|
||||
{user?.role === 'superadmin' && (
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
System
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Settings'))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User Section */}
|
||||
<div className="border-t border-[#ddd8eb] p-4 space-y-2">
|
||||
<div className="border-t border-[var(--neutral-800)] p-4 space-y-2">
|
||||
{isOpen && user && (
|
||||
<div className="px-4 py-3 mb-2">
|
||||
<div className="px-4 py-3 mb-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
|
||||
<div className="h-10 w-10 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold">
|
||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#422268] truncate" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-sm font-medium text-primary dark:text-brand-light-lavender truncate" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-xs text-[#664fa3] capitalize truncate" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-muted-foreground capitalize truncate" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link className='dark:text-brand-lavender ' to='/profile'><Settings size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleThemeToggle}
|
||||
aria-pressed={isDark}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg w-full
|
||||
text-primary dark:text-brand-lavender hover:bg-muted/20 transition-colors
|
||||
${!isOpen && 'justify-center'}
|
||||
`}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5 flex-shrink-0 " />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
{isOpen && <span >{isDark ? 'Light mode' : 'Dark mode'}</span>}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
{isDark ? 'Light mode' : 'Dark mode'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Storage Usage Widget */}
|
||||
<div className="mb-2">
|
||||
{isOpen ? (
|
||||
<div className="px-4 py-3 bg-[#F8F7FB] rounded-lg">
|
||||
<div className="px-4 py-3 bg-[var(--lavender-500)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-[#422268]">Storage Usage</span>
|
||||
<span className="text-xs text-[#664fa3]">{storagePercentage}%</span>
|
||||
<span className="text-sm font-medium text-primary dark:text-brand-light-lavender ">Storage Usage</span>
|
||||
<span className="text-xs text-muted-foreground">{storagePercentage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
|
||||
<div className="w-full bg-[var(--neutral-800)] rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
storagePercentage > 90 ? 'bg-red-500' :
|
||||
className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
|
||||
storagePercentage > 75 ? 'bg-yellow-500' :
|
||||
'bg-[#81B29A]'
|
||||
'bg-[var(--green-light)]'
|
||||
}`}
|
||||
style={{ width: `${storagePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[#664fa3] mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatBytes(storageUsed)} / {formatBytes(storageLimit)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative group">
|
||||
<HardDrive className={`h-5 w-5 ${
|
||||
storagePercentage > 90 ? 'text-red-500' :
|
||||
<HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
|
||||
storagePercentage > 75 ? 'text-yellow-500' :
|
||||
'text-[#664fa3]'
|
||||
'text-muted-foreground'
|
||||
}`} />
|
||||
{storagePercentage > 75 && (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
Storage: {storagePercentage}%
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,7 +487,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
onClick={handleLogout}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg w-full
|
||||
text-[#ff9e77] hover:bg-[#ff9e77]/10 transition-colors
|
||||
text-accent hover:bg-accent/10 transition-colors
|
||||
${!isOpen && 'justify-center'}
|
||||
`}
|
||||
>
|
||||
@@ -357,7 +498,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
{/* Logout tooltip when collapsed */}
|
||||
{!isOpen && (
|
||||
<div className="relative group">
|
||||
<div className="absolute left-full ml-2 bottom-0 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
<div className="absolute left-full ml-2 bottom-0 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
Logout
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,21 +55,21 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto bg-white">
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto bg-background scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Mark Attendance: {event?.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
{rsvps.length === 0 ? (
|
||||
<p className="text-center text-[#664fa3] py-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No RSVPs yet</p>
|
||||
<p className="text-center text-brand-purple py-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No RSVPs yet</p>
|
||||
) : (
|
||||
rsvps.map((rsvp) => (
|
||||
<div
|
||||
key={rsvp.user_id}
|
||||
className="flex items-center gap-3 p-4 border-2 border-[#ddd8eb] rounded-xl hover:border-[#664fa3] transition-colors"
|
||||
className="flex items-center gap-3 p-4 border-2 border-[var(--neutral-800)] rounded-xl hover:border-brand-purple transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={attendance[rsvp.user_id] || false}
|
||||
@@ -79,11 +79,11 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{rsvp.user_name}</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{rsvp.user_email}</p>
|
||||
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{rsvp.user_name}</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{rsvp.user_email}</p>
|
||||
</div>
|
||||
{rsvp.attended && (
|
||||
<span className="text-sm text-[#81B29A] font-medium">
|
||||
<span className="text-sm text-[var(--green-light)] font-medium">
|
||||
✓ Attended
|
||||
</span>
|
||||
)}
|
||||
@@ -96,14 +96,14 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
className="flex-1 bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Attendance'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="outline"
|
||||
className="flex-1 border-2 border-[#ddd8eb] text-[#664fa3] hover:bg-white hover:text-[#422268] rounded-full"
|
||||
className="flex-1 border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-background hover:text-[var(--purple-ink)] rounded-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -66,17 +66,17 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-white">
|
||||
<DialogContent className="sm:max-w-md bg-background">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[#f1eef9]">
|
||||
<Lock className="h-5 w-5 text-[#ff9e77]" />
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--lavender-300)]">
|
||||
<Lock className="h-5 w-5 text-[var(--orange-light)]" />
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Change Password
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Update your password to keep your account secure.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -92,7 +92,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
||||
value={formData.currentPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter current password"
|
||||
className="h-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter new password (min. 6 characters)"
|
||||
className="h-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -120,23 +120,22 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Re-enter new password"
|
||||
className="h-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-full px-6"
|
||||
className="btn-outline mr-33"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6 disabled:opacity-50"
|
||||
className=" btn-primary"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
|
||||
149
src/components/ChangeRoleDialog.js
Normal file
149
src/components/ChangeRoleDialog.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Label } from './ui/label';
|
||||
import { AlertCircle, Shield } from 'lucide-react';
|
||||
import api from '../utils/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function ChangeRoleDialog({ open, onClose, user, onSuccess }) {
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [selectedRole, setSelectedRole] = useState('');
|
||||
const [selectedRoleId, setSelectedRoleId] = useState(null);
|
||||
const [loadingRoles, setLoadingRoles] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRoles();
|
||||
// Pre-select current role
|
||||
setSelectedRole(user.role);
|
||||
setSelectedRoleId(user.role_id);
|
||||
}
|
||||
}, [open, user]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
setLoadingRoles(true);
|
||||
try {
|
||||
// Reuse existing endpoint that returns assignable roles based on privilege
|
||||
const response = await api.get('/admin/roles/assignable');
|
||||
// Map API response to format expected by Select component
|
||||
const mappedRoles = response.data.map(role => ({
|
||||
value: role.code,
|
||||
label: role.name,
|
||||
id: role.id,
|
||||
description: role.description
|
||||
}));
|
||||
setRoles(mappedRoles);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch assignable roles:', error);
|
||||
toast.error('Failed to load roles. Please try again.');
|
||||
} finally {
|
||||
setLoadingRoles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedRole) {
|
||||
toast.error('Please select a role');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't submit if role hasn't changed
|
||||
if (selectedRole === user.role && selectedRoleId === user.role_id) {
|
||||
toast.info('The selected role is the same as current role');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.put(`/admin/users/${user.id}/role`, {
|
||||
role: selectedRole,
|
||||
role_id: selectedRoleId
|
||||
});
|
||||
|
||||
toast.success(`Role changed to ${selectedRole}`);
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.detail || 'Failed to change role';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-[#664fa3]" />
|
||||
Change User Role
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change role for {user.first_name} {user.last_name} ({user.email})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Current Role Display */}
|
||||
<div className="p-3 bg-[#f1eef9] rounded-lg border border-[#DDD8EB]">
|
||||
<p className="text-sm text-gray-600">Current Role</p>
|
||||
<p className="font-semibold text-[#664fa3] capitalize">{user.role}</p>
|
||||
</div>
|
||||
|
||||
{/* Role Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">New Role</Label>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole} disabled={loadingRoles}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select role"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
<span className="capitalize">{role.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Warning for privileged roles */}
|
||||
{(selectedRole === 'admin' || selectedRole === 'superadmin') && (
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-amber-900">Admin Access Warning</p>
|
||||
<p className="text-amber-700">
|
||||
This user will gain full administrative access to the system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="border-2 border-gray-300 rounded-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || loadingRoles}
|
||||
className="bg-[#664fa3] hover:bg-[#7d5ec2] text-white rounded-full"
|
||||
>
|
||||
{submitting ? 'Changing Role...' : 'Change Role'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
111
src/components/ConfirmationDialog.js
Normal file
111
src/components/ConfirmationDialog.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/alert-dialog';
|
||||
import { AlertTriangle, Info, CheckCircle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Reusable confirmation dialog component following the design system
|
||||
*
|
||||
* @param {boolean} open - Controls dialog visibility
|
||||
* @param {function} onOpenChange - Callback when dialog open state changes
|
||||
* @param {function} onConfirm - Callback when user confirms
|
||||
* @param {string} title - Dialog title
|
||||
* @param {string} description - Dialog description/message
|
||||
* @param {string} confirmText - Confirm button text (default: "Confirm")
|
||||
* @param {string} cancelText - Cancel button text (default: "Cancel")
|
||||
* @param {string} variant - Visual variant: 'warning', 'danger', 'info', 'success' (default: 'warning')
|
||||
* @param {boolean} loading - Show loading state on confirm button
|
||||
*/
|
||||
const ConfirmationDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'warning',
|
||||
loading = false,
|
||||
}) => {
|
||||
const variants = {
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-[var(--orange-light)]',
|
||||
confirmButtonClass: 'bg-[var(--orange-light)] text-white hover:bg-[var(--orange-sand)] rounded-full px-6',
|
||||
},
|
||||
danger: {
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-red-600',
|
||||
confirmButtonClass: 'bg-red-600 text-white hover:bg-red-700 rounded-full px-6',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
iconColor: 'text-brand-purple ',
|
||||
confirmButtonClass: 'bg-brand-purple text-white hover:bg-[var(--purple-plum)] rounded-full px-6',
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-[var(--green-light)]',
|
||||
confirmButtonClass: 'bg-[var(--green-light)] text-white hover:bg-[var(--green-pastel)] rounded-full px-6',
|
||||
},
|
||||
};
|
||||
|
||||
const config = variants[variant] || variants.warning;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
|
||||
<AlertDialogHeader className="p-6 pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full bg-[var(--lavender-500)] ${config.iconColor}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AlertDialogTitle
|
||||
className="text-xl font-semibold text-[var(--purple-ink)] mb-2"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription
|
||||
className="text-brand-purple text-sm leading-relaxed"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="p-6 pt-4 bg-[var(--lavender-500)] flex-row gap-3 justify-end">
|
||||
<AlertDialogCancel
|
||||
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-background rounded-full px-6"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
className={config.confirmButtonClass}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Processing...' : confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationDialog;
|
||||
337
src/components/CreateMemberDialog.js
Normal file
337
src/components/CreateMemberDialog.js
Normal file
@@ -0,0 +1,337 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, UserPlus } from 'lucide-react';
|
||||
|
||||
const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipcode: '',
|
||||
date_of_birth: '',
|
||||
member_since: '',
|
||||
role: 'member'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const getTodayDate = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Invalid email format';
|
||||
}
|
||||
|
||||
if (!formData.password || formData.password.length < 8) {
|
||||
newErrors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (!formData.first_name) {
|
||||
newErrors.first_name = 'First name is required';
|
||||
}
|
||||
|
||||
if (!formData.last_name) {
|
||||
newErrors.last_name = 'Last name is required';
|
||||
}
|
||||
|
||||
if (!formData.phone) {
|
||||
newErrors.phone = 'Phone is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Format dates for backend
|
||||
const payload = { ...formData };
|
||||
if (payload.date_of_birth === '') {
|
||||
delete payload.date_of_birth;
|
||||
}
|
||||
if (!payload.member_since) {
|
||||
payload.member_since = getTodayDate();
|
||||
}
|
||||
|
||||
await api.post('/admin/users/create', payload);
|
||||
toast.success('Member created successfully');
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipcode: '',
|
||||
date_of_birth: '',
|
||||
member_since: '',
|
||||
role: 'member'
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to create member';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<UserPlus className="h-6 w-6" />
|
||||
Create Member
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Create a new member account with direct login access. Member will be created immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Email & Password Row */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email" className="text-[var(--purple-ink)]">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="member@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password" className="text-[var(--purple-ink)]">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name Row */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="John"
|
||||
/>
|
||||
{errors.first_name && (
|
||||
<p className="text-sm text-red-500">{errors.first_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
{errors.last_name && (
|
||||
<p className="text-sm text-red-500">{errors.last_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-red-500">{errors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address" className="text-[var(--purple-ink)]">
|
||||
Address
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City, State, Zipcode Row */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="city" className="text-[var(--purple-ink)]">City</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="state" className="text-[var(--purple-ink)]">State</Label>
|
||||
<Input
|
||||
id="state"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleChange('state', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="CA"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="zipcode" className="text-[var(--purple-ink)]">Zipcode</Label>
|
||||
<Input
|
||||
id="zipcode"
|
||||
value={formData.zipcode}
|
||||
onChange={(e) => handleChange('zipcode', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="94102"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates Row */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date_of_birth" className="text-[var(--purple-ink)]">Date of Birth</Label>
|
||||
<Input
|
||||
id="date_of_birth"
|
||||
type="date"
|
||||
value={formData.date_of_birth}
|
||||
onChange={(e) => handleChange('date_of_birth', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="member_since" className="text-[var(--purple-ink)]">Member Since</Label>
|
||||
<Input
|
||||
id="member_since"
|
||||
type="date"
|
||||
value={formData.member_since}
|
||||
onChange={(e) => handleChange('member_since', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-xl"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Create Member
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateMemberDialog;
|
||||
275
src/components/CreateStaffDialog.js
Normal file
275
src/components/CreateStaffDialog.js
Normal file
@@ -0,0 +1,275 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, UserPlus } from 'lucide-react';
|
||||
|
||||
const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
member_since: '',
|
||||
role: 'admin'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const getTodayDate = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Invalid email format';
|
||||
}
|
||||
|
||||
if (!formData.password || formData.password.length < 8) {
|
||||
newErrors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (!formData.first_name) {
|
||||
newErrors.first_name = 'First name is required';
|
||||
}
|
||||
|
||||
if (!formData.last_name) {
|
||||
newErrors.last_name = 'Last name is required';
|
||||
}
|
||||
|
||||
if (!formData.phone) {
|
||||
newErrors.phone = 'Phone is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const payload = { ...formData };
|
||||
if (!payload.member_since) {
|
||||
payload.member_since = getTodayDate();
|
||||
}
|
||||
await api.post('/admin/users/create', payload);
|
||||
toast.success('Staff member created successfully');
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
member_since: '',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to create staff member';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<UserPlus className="h-6 w-6" />
|
||||
Create Staff Member
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Create a new staff account with direct login access. User will be created immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Email */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email" className="text-[var(--purple-ink)]">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="staff@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password" className="text-[var(--purple-ink)]">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* First Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="John"
|
||||
/>
|
||||
{errors.first_name && (
|
||||
<p className="text-sm text-red-500">{errors.first_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
{errors.last_name && (
|
||||
<p className="text-sm text-red-500">{errors.last_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-red-500">{errors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Member Since */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="member_since" className="text-[var(--purple-ink)]">
|
||||
Member Since
|
||||
</Label>
|
||||
<Input
|
||||
id="member_since"
|
||||
type="date"
|
||||
value={formData.member_since}
|
||||
onChange={(e) => handleChange('member_since', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role" className="text-[var(--purple-ink)]">
|
||||
Role <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.role} onValueChange={(value) => handleChange('role', value)}>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="superadmin">Superadmin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-xl"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Create Staff
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateStaffDialog;
|
||||
232
src/components/IdleSessionWarning.js
Normal file
232
src/components/IdleSessionWarning.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import logger from '../utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* IdleSessionWarning Component
|
||||
*
|
||||
* Monitors user activity and warns before session expiration
|
||||
* - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
|
||||
* - Auto-logout on expiration
|
||||
* - "Stay Logged In" extends session
|
||||
*/
|
||||
const IdleSessionWarning = () => {
|
||||
const { user, logout, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Configuration
|
||||
const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||
const WARNING_BEFORE_EXPIRY = 1 * 60 * 1000; // Warn 1 minute before expiry
|
||||
const WARNING_TIME = SESSION_DURATION - WARNING_BEFORE_EXPIRY; // 29 minutes
|
||||
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [timeRemaining, setTimeRemaining] = useState(60); // seconds
|
||||
const [isExtending, setIsExtending] = useState(false);
|
||||
|
||||
const activityTimeoutRef = useRef(null);
|
||||
const warningTimeoutRef = useRef(null);
|
||||
const countdownIntervalRef = useRef(null);
|
||||
const lastActivityRef = useRef(Date.now());
|
||||
|
||||
// Reset activity timer
|
||||
const resetActivityTimer = useCallback(() => {
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
// Clear existing timers
|
||||
if (activityTimeoutRef.current) {
|
||||
clearTimeout(activityTimeoutRef.current);
|
||||
}
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
|
||||
// Hide warning if showing
|
||||
if (showWarning) {
|
||||
setShowWarning(false);
|
||||
}
|
||||
|
||||
// Set new warning timer
|
||||
warningTimeoutRef.current = setTimeout(() => {
|
||||
// Show warning
|
||||
setShowWarning(true);
|
||||
setTimeRemaining(60); // 60 seconds until logout
|
||||
|
||||
// Start countdown
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Time's up - logout
|
||||
handleSessionExpired();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Set auto-logout timer
|
||||
activityTimeoutRef.current = setTimeout(() => {
|
||||
handleSessionExpired();
|
||||
}, WARNING_BEFORE_EXPIRY);
|
||||
|
||||
}, WARNING_TIME);
|
||||
}, [showWarning]);
|
||||
|
||||
// Handle session expiration
|
||||
const handleSessionExpired = useCallback(() => {
|
||||
// Clear all timers
|
||||
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
|
||||
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
|
||||
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
|
||||
|
||||
setShowWarning(false);
|
||||
logout();
|
||||
navigate('/login', {
|
||||
state: { message: 'Your session has expired due to inactivity. Please log in again.' }
|
||||
});
|
||||
}, [logout, navigate]);
|
||||
|
||||
// Handle "Stay Logged In" button
|
||||
const handleExtendSession = async () => {
|
||||
setIsExtending(true);
|
||||
try {
|
||||
// Refresh user data to get new token
|
||||
await refreshUser();
|
||||
|
||||
// Reset activity timer
|
||||
resetActivityTimer();
|
||||
|
||||
logger.log('[IdleSessionWarning] Session extended successfully');
|
||||
} catch (error) {
|
||||
logger.error('[IdleSessionWarning] Failed to extend session:', error);
|
||||
|
||||
// If refresh fails, logout
|
||||
handleSessionExpired();
|
||||
} finally {
|
||||
setIsExtending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Track user activity
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const activityEvents = [
|
||||
'mousedown',
|
||||
'mousemove',
|
||||
'keypress',
|
||||
'scroll',
|
||||
'touchstart',
|
||||
'click'
|
||||
];
|
||||
|
||||
// Throttle activity detection to avoid too many resets
|
||||
let throttleTimeout = null;
|
||||
const handleActivity = () => {
|
||||
if (throttleTimeout) return;
|
||||
|
||||
throttleTimeout = setTimeout(() => {
|
||||
resetActivityTimer();
|
||||
throttleTimeout = null;
|
||||
}, 1000); // Throttle to once per second
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
activityEvents.forEach(event => {
|
||||
document.addEventListener(event, handleActivity, { passive: true });
|
||||
});
|
||||
|
||||
// Initialize timer
|
||||
resetActivityTimer();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
activityEvents.forEach(event => {
|
||||
document.removeEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
|
||||
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
|
||||
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
|
||||
if (throttleTimeout) clearTimeout(throttleTimeout);
|
||||
};
|
||||
}, [user, resetActivityTimer]);
|
||||
|
||||
// Don't render if user is not logged in
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={showWarning} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Prevent closing dialog by clicking outside
|
||||
// User must click a button
|
||||
return;
|
||||
}
|
||||
}}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-[#ff9e77]/10 p-3 rounded-full">
|
||||
<AlertTriangle className="h-6 w-6 text-[#ff9e77]" />
|
||||
</div>
|
||||
<DialogTitle className="text-[#422268]">
|
||||
Session About to Expire
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-[#664fa3]">
|
||||
Your session will expire in <strong className="text-[#422268] text-lg">{timeRemaining}</strong> seconds due to inactivity.
|
||||
|
||||
<div className="mt-4 p-4 bg-[#f1eef9] rounded-lg border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#422268]">
|
||||
Click <strong>"Stay Logged In"</strong> to continue your session, or you will be automatically logged out.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-3 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSessionExpired}
|
||||
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9]"
|
||||
>
|
||||
Log Out Now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExtendSession}
|
||||
disabled={isExtending}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white"
|
||||
>
|
||||
{isExtending ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Extending...
|
||||
</>
|
||||
) : (
|
||||
'Stay Logged In'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdleSessionWarning;
|
||||
334
src/components/ImportMembersDialog.js
Normal file
334
src/components/ImportMembersDialog.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Label } from './ui/label';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from './ui/table';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Badge } from './ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Upload, FileUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
const [file, setFile] = useState(null);
|
||||
const [updateExisting, setUpdateExisting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [importResult, setImportResult] = useState(null);
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileSelect(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (selectedFile) => {
|
||||
if (!selectedFile.name.endsWith('.csv')) {
|
||||
toast.error('Please select a CSV file');
|
||||
return;
|
||||
}
|
||||
setFile(selectedFile);
|
||||
setImportResult(null);
|
||||
};
|
||||
|
||||
const handleFileInput = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileSelect(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
toast.error('Please select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('update_existing', updateExisting);
|
||||
|
||||
const response = await api.post('/admin/users/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
setImportResult(response.data);
|
||||
|
||||
if (response.data.status === 'completed') {
|
||||
toast.success('All members imported successfully!');
|
||||
} else if (response.data.status === 'partial') {
|
||||
toast.warning('Import partially successful. Check errors below.');
|
||||
} else {
|
||||
toast.error('Import failed. Check errors below.');
|
||||
}
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to import members';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFile(null);
|
||||
setUpdateExisting(false);
|
||||
setImportResult(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
if (status === 'completed') {
|
||||
return <CheckCircle className="h-6 w-6 text-green-500" />;
|
||||
} else if (status === 'partial') {
|
||||
return <AlertCircle className="h-6 w-6 text-orange-500" />;
|
||||
} else {
|
||||
return <XCircle className="h-6 w-6 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
completed: { label: 'Completed', className: 'bg-green-500 text-white' },
|
||||
partial: { label: 'Partial Success', className: 'bg-orange-500 text-white' },
|
||||
failed: { label: 'Failed', className: 'bg-red-500 text-white' },
|
||||
};
|
||||
|
||||
const statusConfig = config[status] || config.failed;
|
||||
return (
|
||||
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full`}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[800px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Upload className="h-6 w-6" />
|
||||
{importResult ? 'Import Results' : 'Import Members from CSV'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{importResult
|
||||
? 'Review the import results below'
|
||||
: 'Upload a CSV file to bulk import members. Ensure the CSV has the required columns.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!importResult ? (
|
||||
// Upload Form
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* CSV Format Instructions */}
|
||||
<Alert className="border-brand-purple bg-[var(--lavender-700)]">
|
||||
<AlertDescription className="text-sm text-[var(--purple-ink)]">
|
||||
<strong>Required columns:</strong> Email, First Name, Last Name, Phone, Role
|
||||
<br />
|
||||
<strong>Optional columns:</strong> Status, Address, City, State, Zipcode, Date of Birth, Member Since
|
||||
<br />
|
||||
<strong>Date format:</strong> YYYY-MM-DD (e.g., 2024-01-15)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* File Upload Area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors ${dragActive
|
||||
? 'border-brand-purple bg-[var(--lavender-700)]'
|
||||
: 'border-[var(--neutral-800)] hover:border-brand-purple hover:bg-[var(--lavender-700)]'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<FileUp className="h-16 w-16 text-[var(--green-light)]" />
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
{(file.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFile(null)}
|
||||
className="rounded-xl"
|
||||
>
|
||||
Remove File
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Upload className="h-16 w-16 text-[var(--neutral-800)]" />
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Drag and drop your CSV file here
|
||||
</p>
|
||||
<p className="text-sm text-brand-purple mb-4">or</p>
|
||||
<Label htmlFor="file-upload">
|
||||
<Button variant="outline" className="rounded-xl cursor-pointer" asChild>
|
||||
<span>Browse Files</span>
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-700)] rounded-xl">
|
||||
<Checkbox
|
||||
checked={updateExisting}
|
||||
onCheckedChange={setUpdateExisting}
|
||||
id="update-existing"
|
||||
className="h-5 w-5 border-2 border-brand-purple data-[state=checked]:bg-brand-purple "
|
||||
/>
|
||||
<Label htmlFor="update-existing" className="text-[var(--purple-ink)] cursor-pointer">
|
||||
Update existing members (if email already exists)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Import Results
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-background rounded-xl border border-[var(--neutral-800)] text-center">
|
||||
<p className="text-sm text-brand-purple mb-1">Total Rows</p>
|
||||
<p className="text-2xl font-semibold text-[var(--purple-ink)]">{importResult.total_rows}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-xl border border-green-200 text-center">
|
||||
<p className="text-sm text-green-700 mb-1">Successful</p>
|
||||
<p className="text-2xl font-semibold text-green-600">{importResult.successful_rows}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-red-50 rounded-xl border border-red-200 text-center">
|
||||
<p className="text-sm text-red-700 mb-1">Failed</p>
|
||||
<p className="text-2xl font-semibold text-red-600">{importResult.failed_rows}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background rounded-xl border border-[var(--neutral-800)] flex items-center justify-center gap-2">
|
||||
{getStatusIcon(importResult.status)}
|
||||
{getStatusBadge(importResult.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors Table */}
|
||||
{importResult.errors && importResult.errors.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Errors ({importResult.errors.length} {importResult.errors.length === 10 ? '- showing first 10' : ''})
|
||||
</h3>
|
||||
<div className="border border-[var(--neutral-800)] rounded-xl overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[var(--neutral-800)] hover:bg-[var(--neutral-800)]">
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Row</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Email</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{importResult.errors.map((error, idx) => (
|
||||
<TableRow key={idx} className="hover:bg-[var(--lavender-700)]">
|
||||
<TableCell className="font-medium text-[var(--purple-ink)]">{error.row}</TableCell>
|
||||
<TableCell className="text-brand-purple ">{error.email}</TableCell>
|
||||
<TableCell className="text-red-600 text-sm">{error.error}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!importResult ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="rounded-xl"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading || !file}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import Members
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportMembersDialog;
|
||||
312
src/components/InviteStaffDialog.js
Normal file
312
src/components/InviteStaffDialog.js
Normal file
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../utils/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Mail, Copy, Check } from 'lucide-react';
|
||||
|
||||
const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
role: 'admin'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [invitationUrl, setInvitationUrl] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [loadingRoles, setLoadingRoles] = useState(false);
|
||||
|
||||
// Fetch roles when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRoles();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
setLoadingRoles(true);
|
||||
try {
|
||||
// New endpoint returns roles based on user's permission level
|
||||
// Superadmin: all roles
|
||||
// Admin: admin, finance, and non-elevated custom roles
|
||||
const response = await api.get('/admin/roles/assignable');
|
||||
setRoles(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch assignable roles:', error);
|
||||
toast.error('Failed to load roles. Please try again.');
|
||||
} finally {
|
||||
setLoadingRoles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Invalid email format';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/admin/users/invite', formData);
|
||||
toast.success('Invitation sent successfully');
|
||||
|
||||
// Show invitation URL
|
||||
setInvitationUrl(response.data.invitation_url);
|
||||
|
||||
// Don't close dialog yet - show invitation URL first
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to send invitation';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(invitationUrl);
|
||||
setCopied(true);
|
||||
toast.success('Invitation link copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset form
|
||||
setFormData({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
role: 'admin'
|
||||
});
|
||||
setInvitationUrl(null);
|
||||
setCopied(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[600px] rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Mail className="h-6 w-6" />
|
||||
{invitationUrl ? 'Invitation Sent' : 'Invite Staff Member'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{invitationUrl
|
||||
? 'The invitation has been sent via email. You can also copy the link below.'
|
||||
: 'Send an email invitation to join as staff. They will set their own password.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{invitationUrl ? (
|
||||
// Show invitation URL after successful send
|
||||
<div className="py-4">
|
||||
<Label className="text-[var(--purple-ink)] mb-2 block">Invitation Link (expires in 7 days)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={invitationUrl}
|
||||
readOnly
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] bg-gray-50"
|
||||
/>
|
||||
<Button
|
||||
onClick={copyToClipboard}
|
||||
className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white flex-shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Show invitation form
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Email */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email" className="text-[var(--purple-ink)]">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="staff@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* First Name (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role" className="text-[var(--purple-ink)]">
|
||||
Role <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value) => handleChange('role', value)}
|
||||
disabled={loadingRoles}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select a role"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map(role => (
|
||||
<SelectItem key={role.id} value={role.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{role.name}</span>
|
||||
{role.is_system_role && (
|
||||
<span className="text-xs text-gray-500">(System)</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{roles.length > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{roles.find(r => r.code === formData.role)?.description || ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="rounded-xl"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send Invitation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{invitationUrl && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteStaffDialog;
|
||||
@@ -4,7 +4,7 @@ import { Calendar, Users, User, BookOpen, FileText, DollarSign, Scale } from 'lu
|
||||
|
||||
const MemberFooter = () => {
|
||||
return (
|
||||
<footer className="bg-[#422268] text-white mt-auto">
|
||||
<footer className="bg-brand-dark-lavender text-white mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{/* Logo & About */}
|
||||
@@ -89,12 +89,12 @@ const MemberFooter = () => {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#contact" className="text-gray-300 hover:text-white transition-colors">
|
||||
<a href="/membership/contact-us" className="text-gray-300 hover:text-white transition-colors">
|
||||
Contact Us
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#donate" className="text-gray-300 hover:text-white transition-colors">
|
||||
<a href="/membership/donate" className="text-gray-300 hover:text-white transition-colors">
|
||||
Donate
|
||||
</a>
|
||||
</li>
|
||||
@@ -104,12 +104,12 @@ const MemberFooter = () => {
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-[#664fa3]">
|
||||
<div className="border-t border-[var(--purple-lavender)]">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-400" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-300" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex gap-6">
|
||||
<a href="/#terms" className="hover:text-white transition-colors">Terms of Service</a>
|
||||
<a href="/#privacy" className="hover:text-white transition-colors">Privacy Policy</a>
|
||||
<a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</a>
|
||||
<a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
|
||||
</div>
|
||||
<p>© 2025 LOAF. All rights reserved.</p>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,8 @@ const MemberRoute = ({ children }) => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#FDFCF8]">
|
||||
<p className="text-[#6B708D]">Loading...</p>
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--neutral-200:)]">
|
||||
<p className="text-[var(--slate-muted)]">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Button } from './ui/button';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { ChevronDown, Menu, X } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
const Navbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// LOAF logo (local)
|
||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||
@@ -24,21 +25,21 @@ const Navbar = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top Header - Member Actions */}
|
||||
<header className="bg-gradient-to-r from-[#644c9f] to-[#48286e] px-4 sm:px-8 md:px-16 py-4 flex justify-end items-center gap-4 sm:gap-6">
|
||||
{/* Top Header - Member Actions (Desktop Only) */}
|
||||
<header className="hidden lg:flex bg-gradient-to-r from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-4 sm:px-8 md:px-16 py-4 justify-end items-center gap-4 sm:gap-6">
|
||||
{user && (
|
||||
<span className="text-white text-base font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Welcome, {user.first_name}
|
||||
</span>
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
{(user?.role === 'admin' || user?.role === 'superadmin') && (
|
||||
<Link to="/admin">
|
||||
<button
|
||||
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="admin-nav-button"
|
||||
>
|
||||
Admin Panel
|
||||
Dashboard
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -52,7 +53,7 @@ const Navbar = () => {
|
||||
</button>
|
||||
<Link to="/donate">
|
||||
<Button
|
||||
className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-[54px] py-[10px] text-[16.5px] font-semibold h-[41px]"
|
||||
className="bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-[25px] px-[54px] py-[10px] text-[16.5px] font-semibold h-[41px]"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
Donate
|
||||
@@ -61,11 +62,13 @@ const Navbar = () => {
|
||||
</header>
|
||||
|
||||
{/* Main Header - Member Navigation */}
|
||||
<header className="bg-[#664fa3] px-4 sm:px-8 md:px-16 py-2 flex justify-between items-center">
|
||||
<header className="bg-[var(--purple-lavender)] px-4 sm:px-8 md:px-16 py-2 flex justify-between items-center">
|
||||
<Link to="/dashboard">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
|
||||
</Link>
|
||||
<nav className="flex gap-6 sm:gap-8 md:gap-10 items-center">
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden lg:flex gap-6 sm:gap-8 md:gap-10 items-center">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
@@ -81,21 +84,21 @@ const Navbar = () => {
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
||||
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/history" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/history" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
History
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Mission and Values
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/board" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/board" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Board of Directors
|
||||
</Link>
|
||||
@@ -107,7 +110,7 @@ const Navbar = () => {
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Dashboard
|
||||
My Profile
|
||||
</Link>
|
||||
<Link
|
||||
to="/events"
|
||||
@@ -138,23 +141,264 @@ const Navbar = () => {
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity flex items-center gap-1 bg-transparent border-none cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Documents
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Newsletters
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/financials" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Financials
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/bylaws" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Bylaws
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
</nav>
|
||||
|
||||
{/* Mobile Hamburger Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="lg:hidden p-2 text-white hover:bg-background/10 rounded-lg transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile Menu Drawer */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 h-full w-[280px] bg-gradient-to-b from-[var(--purple-lavender)] to-[var(--purple-deep)] shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={loafLogo} alt="LOAF" className="h-10 w-10 object-contain" />
|
||||
<span className="text-white font-semibold text-lg" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="p-2 text-white hover:bg-background/10 rounded-lg transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
{user && (
|
||||
<div className="px-6 py-4 border-b border-white/20">
|
||||
<p className="text-white text-sm opacity-90" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Welcome,
|
||||
</p>
|
||||
<p className="text-white font-semibold text-base" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-dashboard py-6 px-4">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
to="/members/newsletters"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
to="/"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Documents
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{/* About Us Section */}
|
||||
<div className="space-y-1">
|
||||
<p className="px-4 py-2 text-white/70 text-sm font-semibold uppercase tracking-wider" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
About Us
|
||||
</p>
|
||||
<Link
|
||||
to="/about/history"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
History
|
||||
</Link>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
to="/about/mission-values"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="profile-nav-button"
|
||||
>
|
||||
Mission and Values
|
||||
</Link>
|
||||
<Link
|
||||
to="/about/board"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Board of Directors
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/events"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="mobile-events-nav-button"
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/members/calendar"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Calendar
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/members/directory"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Directory
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/members/gallery"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
|
||||
{/* Documents Section */}
|
||||
<div className="space-y-1">
|
||||
<p className="px-4 py-2 text-white/70 text-sm font-semibold uppercase tracking-wider" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Documents
|
||||
</p>
|
||||
<Link
|
||||
to="/members/newsletters"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Newsletters
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/financials"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/bylaws"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Bylaws
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="mobile-profile-nav-button"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 border-t border-white/20 space-y-3">
|
||||
{(user?.role === 'admin' || user?.role === 'superadmin') && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<Button
|
||||
className="w-full bg-background/20 hover:bg-background/30 text-white rounded-lg"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/donate"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<Button
|
||||
className="w-full bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-lg font-semibold"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
Donate
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full border-2 border-white/30 text-white hover:bg-background/10 rounded-lg"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="mobile-logout-button"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -157,12 +157,12 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] bg-white rounded-2xl">
|
||||
<DialogContent className="sm:max-w-[600px] bg-background rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Activate Manual Payment
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Record offline payment for {user.first_name} {user.last_name} ({user.email})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -170,7 +170,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
<form onSubmit={handleSubmit} className="space-y-6 py-4">
|
||||
{/* Subscription Plan Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan_id" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="plan_id" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Plan
|
||||
</Label>
|
||||
<Select
|
||||
@@ -187,7 +187,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Select subscription plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -203,7 +203,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedPlan && (
|
||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedPlan.description || `${selectedPlan.billing_cycle} subscription`}
|
||||
</p>
|
||||
)}
|
||||
@@ -211,7 +211,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Payment Amount */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="amount" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Amount ($)
|
||||
</Label>
|
||||
<Input
|
||||
@@ -222,11 +222,11 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
placeholder="Enter amount"
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
required
|
||||
/>
|
||||
{selectedPlan && (
|
||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)}
|
||||
</p>
|
||||
)}
|
||||
@@ -234,14 +234,14 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Breakdown Display */}
|
||||
{breakdown && breakdown.total >= breakdown.base && (
|
||||
<Card className="p-4 bg-[#f9f5ff] border border-[#DDD8EB]">
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border border-[var(--neutral-800)]">
|
||||
<div className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex justify-between text-[#422268]">
|
||||
<div className="flex justify-between text-[var(--purple-ink)]">
|
||||
<span>Membership Fee:</span>
|
||||
<span className="font-semibold">{formatPrice(breakdown.base)}</span>
|
||||
</div>
|
||||
{breakdown.donation > 0 && (
|
||||
<div className="flex justify-between text-[#ff9e77]">
|
||||
<div className="flex justify-between text-[var(--orange-light)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4" />
|
||||
Additional Donation:
|
||||
@@ -249,7 +249,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
<span className="font-semibold">{formatPrice(breakdown.donation)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-[#422268] font-bold text-base pt-2 border-t border-[#DDD8EB]">
|
||||
<div className="flex justify-between text-[var(--purple-ink)] font-bold text-base pt-2 border-t border-[var(--neutral-800)]">
|
||||
<span>Total:</span>
|
||||
<span>{formatPrice(breakdown.total)}</span>
|
||||
</div>
|
||||
@@ -259,17 +259,17 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Payment Date */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_date" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="payment_date" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Date
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||
<Input
|
||||
id="payment_date"
|
||||
type="date"
|
||||
value={formData.payment_date}
|
||||
onChange={(e) => setFormData({ ...formData, payment_date: e.target.value })}
|
||||
className="pl-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -277,14 +277,14 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_method" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="payment_method" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Method
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.payment_method}
|
||||
onValueChange={(value) => setFormData({ ...formData, payment_method: value })}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Select payment method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -298,7 +298,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Subscription Period */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>Subscription Period</Label>
|
||||
<Label className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>Subscription Period</Label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -306,9 +306,9 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
id="use_custom_period"
|
||||
checked={useCustomPeriod}
|
||||
onChange={(e) => setUseCustomPeriod(e.target.checked)}
|
||||
className="rounded border-[#ddd8eb]"
|
||||
className="rounded border-[var(--neutral-800)]"
|
||||
/>
|
||||
<Label htmlFor="use_custom_period" className="text-sm text-[#664fa3] font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Label htmlFor="use_custom_period" className="text-sm text-brand-purple font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Use custom dates instead of plan's billing cycle
|
||||
</Label>
|
||||
</div>
|
||||
@@ -316,7 +316,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
{useCustomPeriod ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom_period_start" className="text-sm text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="custom_period_start" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Start Date
|
||||
</Label>
|
||||
<Input
|
||||
@@ -324,12 +324,12 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
type="date"
|
||||
value={formData.custom_period_start}
|
||||
onChange={(e) => setFormData({ ...formData, custom_period_start: e.target.value })}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
required={useCustomPeriod}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom_period_end" className="text-sm text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="custom_period_end" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
End Date
|
||||
</Label>
|
||||
<Input
|
||||
@@ -337,18 +337,18 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
type="date"
|
||||
value={formData.custom_period_end}
|
||||
onChange={(e) => setFormData({ ...formData, custom_period_end: e.target.value })}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
required={useCustomPeriod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
selectedPlan && (
|
||||
<div className="text-sm text-[#664fa3] bg-[#f1eef9] p-3 rounded-lg space-y-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-sm text-brand-purple bg-[var(--lavender-300)] p-3 rounded-lg space-y-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedPlan.custom_cycle_enabled ? (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-medium text-[#422268]">Plan uses custom billing cycle:</span>
|
||||
<span className="font-medium text-[var(--purple-ink)]">Plan uses custom billing cycle:</span>
|
||||
<br />
|
||||
{(() => {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
@@ -378,7 +378,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="notes" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Notes (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
@@ -386,7 +386,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
placeholder="Additional notes about the payment..."
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3] min-h-[100px]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -395,14 +395,14 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-full border-2 border-[#ddd8eb]"
|
||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087] rounded-full"
|
||||
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)] rounded-full"
|
||||
>
|
||||
{loading ? 'Activating...' : 'Activate Payment'}
|
||||
</Button>
|
||||
|
||||
235
src/components/PendingInvitationsTable.js
Normal file
235
src/components/PendingInvitationsTable.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from './ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { Mail, Trash2, MailCheck, Clock } from 'lucide-react';
|
||||
|
||||
const PendingInvitationsTable = () => {
|
||||
const [invitations, setInvitations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revokeDialog, setRevokeDialog] = useState({ open: false, invitation: null });
|
||||
const [resending, setResending] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvitations();
|
||||
}, []);
|
||||
|
||||
const fetchInvitations = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/users/invitations?status=pending');
|
||||
setInvitations(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch invitations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async (invitationId) => {
|
||||
setResending(invitationId);
|
||||
try {
|
||||
await api.post(`/admin/users/invitations/${invitationId}/resend`);
|
||||
toast.success('Invitation resent successfully');
|
||||
fetchInvitations(); // Refresh list to show new expiry date
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to resend invitation');
|
||||
} finally {
|
||||
setResending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!revokeDialog.invitation) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/admin/users/invitations/${revokeDialog.invitation.id}`);
|
||||
toast.success('Invitation revoked');
|
||||
setRevokeDialog({ open: false, invitation: null });
|
||||
fetchInvitations(); // Refresh list
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to revoke invitation');
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
const config = {
|
||||
superadmin: { label: 'Superadmin', className: 'bg-brand-purple text-white' },
|
||||
admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' },
|
||||
member: { label: 'Member', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' }
|
||||
};
|
||||
|
||||
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`}>
|
||||
{roleConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const isExpiringSoon = (expiresAt) => {
|
||||
const expiry = new Date(expiresAt);
|
||||
const now = new Date();
|
||||
const hoursDiff = (expiry - now) / (1000 * 60 * 60);
|
||||
return hoursDiff < 24 && hoursDiff > 0;
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const hoursDiff = (date - now) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff < 0) {
|
||||
return 'Expired';
|
||||
} else if (hoursDiff < 24) {
|
||||
return `Expires in ${Math.round(hoursDiff)} hours`;
|
||||
} else {
|
||||
const daysDiff = Math.round(hoursDiff / 24);
|
||||
return `Expires in ${daysDiff} day${daysDiff > 1 ? 's' : ''}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading invitations...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (invitations.length === 0) {
|
||||
return (
|
||||
<Card className="p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center">
|
||||
<Mail className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Pending Invitations
|
||||
</h3>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
All invitations have been accepted or expired
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[var(--neutral-800)] hover:bg-[var(--neutral-800)]">
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Email</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Name</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Role</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Invited</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Expires</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invitations.map((invitation) => (
|
||||
<TableRow key={invitation.id} className="hover:bg-[var(--lavender-700)]">
|
||||
<TableCell className="font-medium text-[var(--purple-ink)]">
|
||||
{invitation.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-brand-purple ">
|
||||
{invitation.first_name && invitation.last_name
|
||||
? `${invitation.first_name} ${invitation.last_name}`
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>{getRoleBadge(invitation.role)}</TableCell>
|
||||
<TableCell className="text-brand-purple ">
|
||||
{new Date(invitation.invited_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className={`h-4 w-4 ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500' : 'text-brand-purple '}`} />
|
||||
<span className={`text-sm ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500 font-semibold' : 'text-brand-purple '}`}>
|
||||
{formatDate(invitation.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResend(invitation.id)}
|
||||
disabled={resending === invitation.id}
|
||||
className="rounded-xl border-[var(--green-light)] text-[var(--green-light)] hover:bg-[var(--green-light)] hover:text-white"
|
||||
>
|
||||
{resending === invitation.id ? (
|
||||
'Resending...'
|
||||
) : (
|
||||
<>
|
||||
<MailCheck className="h-4 w-4 mr-1" />
|
||||
Resend
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRevokeDialog({ open: true, invitation })}
|
||||
className="rounded-xl border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Revoke Confirmation Dialog */}
|
||||
<AlertDialog open={revokeDialog.open} onOpenChange={(open) => setRevokeDialog({ open, invitation: null })}>
|
||||
<AlertDialogContent className="rounded-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Revoke Invitation
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Are you sure you want to revoke the invitation for{' '}
|
||||
<span className="font-semibold">{revokeDialog.invitation?.email}</span>?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRevoke}
|
||||
className="rounded-xl bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingInvitationsTable;
|
||||
@@ -159,12 +159,12 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{plan ? 'Edit Plan' : 'Create New Plan'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{plan ? 'Update plan details below' : 'Enter plan details to create a new subscription plan'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -197,8 +197,8 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
</div>
|
||||
|
||||
{/* Dynamic Pricing */}
|
||||
<div className="border-2 border-[#DDD8EB] rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="border-2 border-[var(--neutral-800)] rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Dynamic Pricing
|
||||
</h3>
|
||||
|
||||
@@ -216,7 +216,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
required
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-xs text-[#664fa3] mt-1">Minimum $30</p>
|
||||
<p className="text-xs text-brand-purple mt-1">Minimum $30</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -232,7 +232,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
required
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-xs text-[#664fa3] mt-1">Pre-filled amount</p>
|
||||
<p className="text-xs text-brand-purple mt-1">Pre-filled amount</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -240,7 +240,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div>
|
||||
<Label htmlFor="allow_donation">Allow Donations</Label>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Members can pay more than minimum
|
||||
</p>
|
||||
</div>
|
||||
@@ -252,7 +252,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
onChange={(e) => setFormData({ ...formData, allow_donation: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#664fa3]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#81B29A]"></div>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-purple /20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--green-light)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,11 +279,11 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
|
||||
{/* Custom Billing Cycle Dates */}
|
||||
{formData.billing_cycle === 'custom' && (
|
||||
<div className="border-2 border-[#DDD8EB] rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="border-2 border-[var(--neutral-800)] rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Custom Billing Period
|
||||
</h3>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Set recurring date range (e.g., Jan 1 - Dec 31 for calendar year)
|
||||
</p>
|
||||
|
||||
@@ -349,8 +349,8 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f9f5ff] border border-[#DDD8EB] rounded p-3">
|
||||
<p className="text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="bg-[var(--lavender-400)] border border-[var(--neutral-800)] rounded p-3">
|
||||
<p className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Example:</strong> Jan 1 - Dec 31 for calendar year, or Jul 1 - Jun 30 for fiscal year
|
||||
</p>
|
||||
</div>
|
||||
@@ -361,7 +361,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="active">Active Status</Label>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Inactive plans won't appear for new subscriptions
|
||||
</p>
|
||||
</div>
|
||||
@@ -373,7 +373,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#664fa3]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#81B29A]"></div>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-purple /20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--green-light)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -389,7 +389,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
@@ -8,37 +8,39 @@ const PublicFooter = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Main Footer */}
|
||||
<footer className="bg-[#644c9f] px-4 sm:px-8 md:px-16 py-12 md:py-20 flex items-center justify-center min-h-[420px]">
|
||||
<div className="border-t border-[rgba(0,0,0,0.1)] py-8 md:py-12 lg:py-20 flex flex-col lg:flex-row gap-8 sm:gap-12 md:gap-16 lg:gap-20 xl:gap-30 items-center justify-center w-full max-w-7xl">
|
||||
<div className="w-32 sm:w-40 md:w-48 lg:w-[232px] flex-shrink-0">
|
||||
<footer className="bg-[var(--purple-amethyst)] border-t border-[rgba(0,0,0,0.1)] py-8 px-2 lg:px-10 flex items-center justify-between">
|
||||
<div className=" flex flex-col md:flex-row gap-14 md:gap-2 lg:gap-32 xl:gap-40 items-center justify-center text-left md:justify-between w-full max-w-7xl mx-auto">
|
||||
<div className="w-40 sm:w-40 md:w-48 lg:w-[180px] flex-shrink-0">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="w-full h-auto aspect-square object-contain" />
|
||||
</div>
|
||||
<nav className="flex flex-col sm:flex-row gap-8 sm:gap-12 md:gap-16 lg:gap-20 xl:gap-28 items-start justify-center w-full lg:w-auto">
|
||||
<div className="flex flex-col gap-2 w-full sm:w-auto sm:min-w-[163px]">
|
||||
<div className="pb-4">
|
||||
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>About</p>
|
||||
<nav className="flex flex-col sm:flex-row sm:flex-nowrap gap-8 sm:gap-4 lg:gap-20 xl:gap-28 items-start justify-center w-full lg:w-auto">
|
||||
|
||||
<div className="md:flex hidden flex-col gap-2 items-start text-left w-full sm:w-auto sm:min-w-[163px]">
|
||||
<div className="pb-2 lg:pb-4">
|
||||
<p className="text-white text-xl font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>About</p>
|
||||
</div>
|
||||
<Link to="/about/history" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>History</Link>
|
||||
<Link to="/about/mission-values" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Mission and Values</Link>
|
||||
<Link to="/about/board" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Board of Directors</Link>
|
||||
<Link to="/about/history" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>History</Link>
|
||||
<Link to="/about/mission-values" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Mission and Values</Link>
|
||||
<Link to="/about/board" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Board of Directors</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full sm:w-auto sm:min-w-[148px]">
|
||||
<div className="pb-4">
|
||||
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Connect</p>
|
||||
<div className="hidden md:flex flex-col gap-2 items-start text-left w-full sm:w-auto sm:min-w-[148px]">
|
||||
<div className="pb-2 lg:pb-4">
|
||||
<p className="text-white text-xl font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>Connect</p>
|
||||
</div>
|
||||
<Link to="/become-a-member" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Become a Member</Link>
|
||||
<Link to="/contact-us" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Contact Us</Link>
|
||||
<Link to="/resources" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Inter', sans-serif" }}>Resources</Link>
|
||||
<Link to="/become-a-member" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Become a Member</Link>
|
||||
<Link to="/contact-us" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Contact Us</Link>
|
||||
<Link to="/resources" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Resources</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center sm:items-start w-full sm:w-auto sm:min-w-[220px] md:min-w-[271px]">
|
||||
<div className="pb-4 w-full">
|
||||
|
||||
<div className="flex flex-col gap-2 items-center justify-center md:items-start text-left w-full sm:w-auto sm:min-w-[200px] md:min-w-[200px] lg:min-w-[220px]">
|
||||
<div className="pb-4 w-full flex justify-center lg:justify-start">
|
||||
<Link to="/donate" className="block">
|
||||
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-full px-6 py-3 text-base sm:text-lg font-medium w-full sm:w-[217px]">
|
||||
<Button className="bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-full px-12 lg:px-16 py-6 text-lg sm:text-lg font-medium ">
|
||||
Donate
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-[#ddd8eb] text-sm sm:text-base font-medium text-center sm:text-left w-full" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-[var(--neutral-800)] text-sm sm:text-base font-medium text-center md:text-left w-full" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF is supported by<br />the Hollyfield Foundation
|
||||
</p>
|
||||
</div>
|
||||
@@ -47,22 +49,22 @@ const PublicFooter = () => {
|
||||
</footer>
|
||||
|
||||
{/* Bottom Footer */}
|
||||
<footer className="bg-gradient-to-r from-[#48286e] to-[#644c9f] border-t border-[rgba(0,0,0,0.1)] px-4 sm:px-8 md:px-16 py-6 md:py-8">
|
||||
<footer className="bg-gradient-to-r from-[var(--purple-deep)] to-[var(--purple-amethyst)] border-t border-[rgba(0,0,0,0.1)] px-4 sm:px-8 md:px-20 py-5">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 justify-between items-center max-w-7xl mx-auto">
|
||||
<nav className="flex flex-col sm:flex-row gap-4 sm:gap-8 items-center order-1 sm:order-none">
|
||||
<a href="/#terms" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Link to="/terms-of-service" className="text-[var(--neutral-500)] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Terms of Service
|
||||
</a>
|
||||
<a href="/#privacy" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
</Link>
|
||||
<Link to="/privacy-policy" className="text-[var(--neutral-500)] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
<p className="text-[#c5b4e3] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
© 2025 LOAF. All Rights Reserved.
|
||||
</p>
|
||||
<p className="text-[#c5b4e3] text-sm sm:text-base font-medium text-center order-3 sm:order-none" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-3 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Designed and Managed by{' '}
|
||||
<a href="https://konceptkit.com/" className="text-[#d1c3e9] underline hover:text-white transition-colors whitespace-nowrap">
|
||||
<a href="https://konceptkit.com/" className=" text-white transition-colors whitespace-nowrap">
|
||||
Koncept Kit
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Button } from './ui/button';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ChevronDown, Menu, X } from 'lucide-react';
|
||||
@@ -13,8 +13,23 @@ import {
|
||||
const PublicNavbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// Helper function to check if a path is active
|
||||
const isActive = (path) => {
|
||||
if (path.includes('#')) {
|
||||
// For hash links like /#welcome
|
||||
return location.pathname + location.hash === path || location.hash === path.replace('/', '');
|
||||
}
|
||||
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||
};
|
||||
|
||||
// Check if any About Us sub-page is active
|
||||
const isAboutActive = () => {
|
||||
return location.pathname.startsWith('/about');
|
||||
};
|
||||
|
||||
// LOAF logo (local)
|
||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||
|
||||
@@ -27,14 +42,45 @@ const PublicNavbar = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Active and inactive link styles for desktop
|
||||
const getDesktopLinkClasses = (path) => {
|
||||
const baseClasses = "text-[17.5px] font-medium transition-all px-3 py-1 rounded-md";
|
||||
if (isActive(path)) {
|
||||
return `${baseClasses} text-[var(--orange-light)] hover:text-[var(--orange-coral)] `;
|
||||
}
|
||||
return `${baseClasses} text-white hover:opacity-80`;
|
||||
};
|
||||
|
||||
// Active and inactive link styles for mobile
|
||||
const getMobileLinkClasses = (path) => {
|
||||
const baseClasses = "text-base font-medium px-4 py-3 rounded-md transition-colors";
|
||||
if (isActive(path)) {
|
||||
return `${baseClasses} bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)]`;
|
||||
}
|
||||
return `${baseClasses} text-white hover:bg-[var(--purple-deep)]`;
|
||||
};
|
||||
|
||||
// Active and inactive link styles for mobile sub-items (About Us)
|
||||
const getMobileSubLinkClasses = (path) => {
|
||||
const baseClasses = "text-sm font-medium px-6 py-2 rounded-md transition-colors block";
|
||||
if (isActive(path)) {
|
||||
return `${baseClasses} bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)]`;
|
||||
}
|
||||
return `${baseClasses} text-[var(--neutral-800)] hover:bg-[var(--purple-deep)] hover:text-white`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top Header - Auth Actions */}
|
||||
<header className="bg-gradient-to-r from-[#644c9f] to-[#48286e] px-4 sm:px-8 md:px-16 py-4 flex justify-end items-center gap-4 sm:gap-6">
|
||||
<div className='sticky top-0 inset-x-0 z-50'>
|
||||
|
||||
<header className="bg-gradient-to-r flex-wrap from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-[20px] py-[10px] flex md:justify-end justify-between items-center gap-4 sm:gap-6">
|
||||
<div className='flex gap-4 sm:gap-6'>
|
||||
|
||||
<button
|
||||
onClick={handleAuthAction}
|
||||
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{user ? 'Logout' : 'Login'}
|
||||
</button>
|
||||
@@ -42,14 +88,15 @@ const PublicNavbar = () => {
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-white text-base font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/donate">
|
||||
<Button
|
||||
className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-[54px] py-[10px] text-[16.5px] font-semibold h-[41px]"
|
||||
className="bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-[25px] px-[50px] py-[5px] text-[16.5px] font-semibold h-[41px]"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
Donate
|
||||
@@ -58,7 +105,7 @@ const PublicNavbar = () => {
|
||||
</header>
|
||||
|
||||
{/* Main Header - Navigation */}
|
||||
<header className="bg-[#664fa3] px-4 sm:px-8 md:px-16 py-2 flex justify-between items-center">
|
||||
<header className=" bg-brand-purple px-[20px] py-2 flex justify-between items-center">
|
||||
<Link to="/">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
|
||||
</Link>
|
||||
@@ -66,45 +113,48 @@ const PublicNavbar = () => {
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="lg:hidden p-2 text-white hover:bg-[#48286e] rounded-md transition-colors"
|
||||
className="lg:hidden p-2 text-white hover:bg-[var(--purple-deep)] rounded-md transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
<Menu className="size-14" />
|
||||
</button>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden lg:flex gap-10 items-center">
|
||||
<nav className="hidden lg:flex gap-6 items-center">
|
||||
<Link
|
||||
to="/#welcome"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getDesktopLinkClasses('/#welcome')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Welcome
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity flex items-center gap-1 bg-transparent border-none cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<button
|
||||
className={`${isAboutActive()
|
||||
? "text-[var(--orange-light)] hover:text-[var(--orange-coral)]"
|
||||
: "text-white hover:opacity-80"} text-[17.5px] font-medium transition-all flex items-center gap-1 bg-transparent border-none cursor-pointer px-3 py-1 rounded-md`}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
About Us
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
||||
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/history" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<Link to="/about/history" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
History
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Mission and Values
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/board" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<Link to="/about/board" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Board of Directors
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -112,37 +162,38 @@ const PublicNavbar = () => {
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
to={user ? "/dashboard" : "/become-a-member"}
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getDesktopLinkClasses(user ? "/dashboard" : "/become-a-member")}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{user ? 'Dashboard' : 'Become a Member'}
|
||||
{user ? 'My Profile' : 'Become a Member'}
|
||||
</Link>
|
||||
{!user && (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getDesktopLinkClasses('/login')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Members Only
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/resources"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getDesktopLinkClasses('/resources')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Resources
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact-us"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getDesktopLinkClasses('/contact-us')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
</div>
|
||||
{/* Mobile Menu Drawer */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
@@ -153,15 +204,15 @@ const PublicNavbar = () => {
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 h-full w-[280px] bg-[#664fa3] shadow-xl overflow-y-auto">
|
||||
<div className="fixed right-0 top-0 h-full w-[280px] bg-brand-purple shadow-xl overflow-y-auto scrollbar-dashboard">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-[#48286e]">
|
||||
<span className="text-white text-lg font-semibold" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<div className="flex justify-between items-center p-6 border-b border-[var(--purple-deep)]">
|
||||
<span className="text-white text-lg font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Menu
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="p-2 text-white hover:bg-[#48286e] rounded-md transition-colors"
|
||||
className="p-2 text-white hover:bg-[var(--purple-deep)] rounded-md transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
@@ -173,38 +224,41 @@ const PublicNavbar = () => {
|
||||
<Link
|
||||
to="/#welcome"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getMobileLinkClasses('/#welcome')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Welcome
|
||||
</Link>
|
||||
|
||||
{/* About Us Section */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-white text-base font-semibold px-4 py-2" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<p
|
||||
className={`text-base font-semibold px-4 py-2 rounded-md ${isAboutActive() ? 'text-[var(--orange-light)]' : 'text-white'}`}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
About Us
|
||||
</p>
|
||||
<Link
|
||||
to="/about/history"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-[#ddd8eb] text-sm font-medium hover:bg-[#48286e] hover:text-white px-6 py-2 rounded-md transition-colors block"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getMobileSubLinkClasses('/about/history')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
History
|
||||
</Link>
|
||||
<Link
|
||||
to="/about/mission-values"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-[#ddd8eb] text-sm font-medium hover:bg-[#48286e] hover:text-white px-6 py-2 rounded-md transition-colors block"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getMobileSubLinkClasses('/about/mission-values')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Mission and Values
|
||||
</Link>
|
||||
<Link
|
||||
to="/about/board"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-[#ddd8eb] text-sm font-medium hover:bg-[#48286e] hover:text-white px-6 py-2 rounded-md transition-colors block"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getMobileSubLinkClasses('/about/board')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Board of Directors
|
||||
</Link>
|
||||
@@ -213,18 +267,18 @@ const PublicNavbar = () => {
|
||||
<Link
|
||||
to={user ? "/dashboard" : "/become-a-member"}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getMobileLinkClasses(user ? "/dashboard" : "/become-a-member")}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{user ? 'Dashboard' : 'Become a Member'}
|
||||
{user ? 'My Profile' : 'Become a Member'}
|
||||
</Link>
|
||||
|
||||
{!user && (
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getMobileLinkClasses('/login')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Members Only
|
||||
</Link>
|
||||
@@ -233,8 +287,8 @@ const PublicNavbar = () => {
|
||||
<Link
|
||||
to="/resources"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getMobileLinkClasses('/resources')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Resources
|
||||
</Link>
|
||||
@@ -242,21 +296,21 @@ const PublicNavbar = () => {
|
||||
<Link
|
||||
to="/contact-us"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className={getMobileLinkClasses('/contact-us')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
|
||||
{/* Auth Actions */}
|
||||
<div className="pt-4 border-t border-[#48286e] space-y-2">
|
||||
<div className="pt-4 border-t border-[var(--purple-deep)] space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleAuthAction();
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className="w-full text-left text-white text-base font-medium hover:bg-[var(--purple-deep)] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{user ? 'Logout' : 'Login'}
|
||||
</button>
|
||||
@@ -264,8 +318,8 @@ const PublicNavbar = () => {
|
||||
<Link
|
||||
to="/register"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className="block text-white text-base font-medium hover:bg-[var(--purple-deep)] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
@@ -275,7 +329,7 @@ const PublicNavbar = () => {
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block w-full"
|
||||
>
|
||||
<Button className="w-full bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-6 py-3 text-base font-semibold">
|
||||
<Button className="w-full bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-[25px] px-6 py-3 text-base font-semibold">
|
||||
Donate
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
107
src/components/RejectionDialog.js
Normal file
107
src/components/RejectionDialog.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Label } from './ui/label';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
export default function RejectionDialog({ open, onOpenChange, onConfirm, user, loading }) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!reason.trim()) {
|
||||
setError('Rejection reason is required');
|
||||
return;
|
||||
}
|
||||
onConfirm(reason);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setReason('');
|
||||
setError('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px] rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-3 bg-red-100 rounded-full">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Reject Application
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You are about to reject <strong>{user?.first_name} {user?.last_name}</strong>'s membership application.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="bg-[var(--lavender-400)] border border-[var(--neutral-800)] rounded-lg p-4">
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Applicant:</strong> {user?.email}
|
||||
</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Status:</strong> {user?.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason" className="text-[var(--purple-ink)] font-medium">
|
||||
Rejection Reason <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => {
|
||||
setReason(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="Please provide a clear reason for rejection. This will be sent to the applicant."
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-red-500 min-h-[120px]"
|
||||
disabled={loading}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The applicant will receive an email with this reason.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
variant="outline"
|
||||
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="bg-red-600 text-white hover:bg-red-700 rounded-full px-6"
|
||||
disabled={loading}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Rejecting...' : 'Confirm Rejection'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
36
src/components/StatCard.jsx
Normal file
36
src/components/StatCard.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Card } from "./ui/card";
|
||||
|
||||
export const StatCard = ({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
iconBgClass,
|
||||
dataTestId,
|
||||
}) => (
|
||||
<Card
|
||||
className="p-6 flex flex-col justify-between bg-background rounded-2xl border border-[var(--neutral-800)]"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-4 ">
|
||||
<div className={`${iconBgClass} p-3 rounded-lg `}>
|
||||
<Icon className="size-8" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<p
|
||||
className="text-6xl font-semibold text-[var(--purple-ink)] mb-1"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-brand-purple "
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
252
src/components/TransactionHistory.js
Normal file
252
src/components/TransactionHistory.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Receipt, CreditCard, Heart, Calendar, ExternalLink, DollarSign } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* TransactionHistory Component
|
||||
* Displays user transaction history including subscriptions and donations
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.subscriptions - List of subscription transactions
|
||||
* @param {Array} props.donations - List of donation transactions
|
||||
* @param {number} props.totalSubscriptionCents - Total subscription amount in cents
|
||||
* @param {number} props.totalDonationCents - Total donation amount in cents
|
||||
* @param {boolean} props.loading - Loading state
|
||||
* @param {boolean} props.isAdmin - Whether viewing as admin (shows extra fields)
|
||||
*/
|
||||
const TransactionHistory = ({
|
||||
subscriptions = [],
|
||||
donations = [],
|
||||
totalSubscriptionCents = 0,
|
||||
totalDonationCents = 0,
|
||||
loading = false,
|
||||
isAdmin = false
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
const formatAmount = (cents) => {
|
||||
if (!cents) return '$0.00';
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadgeClass = (status) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'active':
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'cancelled':
|
||||
case 'failed':
|
||||
case 'expired':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const allTransactions = [
|
||||
...subscriptions.map(s => ({ ...s, sortDate: s.created_at })),
|
||||
...donations.map(d => ({ ...d, sortDate: d.created_at }))
|
||||
].sort((a, b) => new Date(b.sortDate) - new Date(a.sortDate));
|
||||
|
||||
const TransactionRow = ({ transaction }) => {
|
||||
const isSubscription = transaction.type === 'subscription';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-[var(--neutral-800)] last:border-b-0 hover:bg-[var(--lavender-500)] transition-colors">
|
||||
<div className="flex items-start gap-3 mb-2 sm:mb-0">
|
||||
<div className={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
|
||||
{isSubscription ? (
|
||||
<CreditCard className="h-5 w-5 text-[var(--purple-lavender)]" />
|
||||
) : (
|
||||
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{transaction.description}
|
||||
</span>
|
||||
<Badge className={`text-xs ${getStatusBadgeClass(transaction.status)}`}>
|
||||
{transaction.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-brand-purple mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(transaction.payment_completed_at || transaction.created_at)}</span>
|
||||
{transaction.card_brand && transaction.card_last4 && (
|
||||
<>
|
||||
<span className="text-[var(--neutral-800)]">•</span>
|
||||
<span>{transaction.card_brand} ****{transaction.card_last4}</span>
|
||||
</>
|
||||
)}
|
||||
{isSubscription && transaction.billing_cycle && (
|
||||
<>
|
||||
<span className="text-[var(--neutral-800)]">•</span>
|
||||
<span className="capitalize">{transaction.billing_cycle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && transaction.manual_payment && (
|
||||
<div className="text-xs text-[var(--orange-light)] mt-1">
|
||||
Manual Payment {transaction.manual_payment_notes && `- ${transaction.manual_payment_notes}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-10 sm:pl-0">
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(transaction.amount_cents)}
|
||||
</div>
|
||||
{isSubscription && transaction.donation_cents > 0 && (
|
||||
<div className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
(incl. {formatAmount(transaction.donation_cents)} donation)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{transaction.stripe_receipt_url && (
|
||||
<a
|
||||
href={transaction.stripe_receipt_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 text-brand-purple hover:text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg transition-colors"
|
||||
title="View Receipt"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = ({ type }) => (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-[var(--lavender-300)] rounded-full flex items-center justify-center mb-4">
|
||||
{type === 'subscription' ? (
|
||||
<CreditCard className="h-8 w-8 text-brand-purple" />
|
||||
) : type === 'donation' ? (
|
||||
<Heart className="h-8 w-8 text-brand-purple" />
|
||||
) : (
|
||||
<Receipt className="h-8 w-8 text-brand-purple" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{type === 'subscription'
|
||||
? 'No subscription payments yet'
|
||||
: type === 'donation'
|
||||
? 'No donations yet'
|
||||
: 'No transactions yet'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--purple-lavender)]"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Receipt className="h-6 w-6 text-brand-purple" />
|
||||
Transaction History
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
<span className="text-sm">Total Subscriptions</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(totalSubscriptionCents)}
|
||||
</div>
|
||||
<div className="text-xs text-brand-purple mt-1">{subscriptions.length} payment(s)</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Heart className="h-4 w-4" />
|
||||
<span className="text-sm">Total Donations</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(totalDonationCents)}
|
||||
</div>
|
||||
<div className="text-xs text-brand-purple mt-1">{donations.length} donation(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-4">
|
||||
<TabsTrigger value="all" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
All ({allTransactions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subscriptions" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
Subscriptions ({subscriptions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="donations" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
Donations ({donations.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="border border-[var(--neutral-800)] rounded-xl overflow-hidden">
|
||||
<TabsContent value="all" className="m-0">
|
||||
{allTransactions.length > 0 ? (
|
||||
allTransactions.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="all" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscriptions" className="m-0">
|
||||
{subscriptions.length > 0 ? (
|
||||
subscriptions.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="subscription" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="donations" className="m-0">
|
||||
{donations.length > 0 ? (
|
||||
donations.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="donation" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionHistory;
|
||||
987
src/components/WordPressImportWizard.js
Normal file
987
src/components/WordPressImportWizard.js
Normal file
@@ -0,0 +1,987 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import api from '../utils/api';
|
||||
import {
|
||||
Upload,
|
||||
FileCheck,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
FileDown,
|
||||
Users,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* WordPress Import Wizard Component
|
||||
*
|
||||
* A comprehensive 6-step wizard for importing WordPress users to LOAF platform.
|
||||
* Features:
|
||||
* - CSV upload and analysis
|
||||
* - Interactive status review and adjustment
|
||||
* - Preview before import
|
||||
* - Real-time import progress
|
||||
* - Full rollback capability
|
||||
* - Error reporting
|
||||
*/
|
||||
export default function WordPressImportWizard({ open, onOpenChange, onSuccess }) {
|
||||
// Wizard state
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [importJobId, setImportJobId] = useState(null);
|
||||
|
||||
// Data state
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [analysisResult, setAnalysisResult] = useState(null);
|
||||
const [previewData, setPreviewData] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Override state
|
||||
const [statusOverrides, setStatusOverrides] = useState({});
|
||||
const [selectedRows, setSelectedRows] = useState(new Set());
|
||||
|
||||
// Import execution state
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [importResults, setImportResults] = useState(null);
|
||||
|
||||
// UI state
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Step definitions
|
||||
const steps = [
|
||||
{ number: 1, title: 'Upload CSV', icon: Upload },
|
||||
{ number: 2, title: 'Field Mapping', icon: FileCheck },
|
||||
{ number: 3, title: 'Review Status', icon: CheckCircle },
|
||||
{ number: 4, title: 'Preview', icon: Eye },
|
||||
{ number: 5, title: 'Execute', icon: Play },
|
||||
{ number: 6, title: 'Results', icon: CheckCircle2 }
|
||||
];
|
||||
|
||||
// Reset wizard state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setCurrentStep(1);
|
||||
setImportJobId(null);
|
||||
setUploadedFile(null);
|
||||
setAnalysisResult(null);
|
||||
setPreviewData([]);
|
||||
setStatusOverrides({});
|
||||
setSelectedRows(new Set());
|
||||
setImporting(false);
|
||||
setImportProgress(0);
|
||||
setImportResults(null);
|
||||
}, 300); // Wait for dialog close animation
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// ============================================================================
|
||||
// Step Navigation
|
||||
// ============================================================================
|
||||
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return uploadedFile && analysisResult;
|
||||
case 2:
|
||||
return true; // Field mapping auto-detected
|
||||
case 3:
|
||||
return true; // Status review is optional
|
||||
case 4:
|
||||
return true; // Preview is informational
|
||||
case 5:
|
||||
return !importing;
|
||||
case 6:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 6 && canProceed()) {
|
||||
if (currentStep === 3) {
|
||||
// Load preview data when moving from step 3 to 4
|
||||
loadPreviewData(1);
|
||||
}
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 1: Upload CSV
|
||||
// ============================================================================
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.csv')) {
|
||||
toast.error('Please upload a CSV file');
|
||||
return;
|
||||
}
|
||||
setUploadedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadedFile);
|
||||
|
||||
try {
|
||||
const response = await api.post('/admin/import/upload-csv', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
setImportJobId(response.data.import_job_id);
|
||||
setAnalysisResult(response.data);
|
||||
toast.success(`CSV analyzed: ${response.data.valid_rows} valid rows, ${response.data.warnings} warnings`);
|
||||
|
||||
// Auto-advance to next step
|
||||
setTimeout(() => setCurrentStep(2), 500);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to upload CSV');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 3: Review & Adjust Status
|
||||
// ============================================================================
|
||||
|
||||
const loadPreviewData = async (page = 1) => {
|
||||
if (!importJobId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/admin/import/${importJobId}/preview`, {
|
||||
params: { page, page_size: 50 }
|
||||
});
|
||||
|
||||
setPreviewData(response.data.rows);
|
||||
setCurrentPage(response.data.page);
|
||||
setTotalPages(response.data.total_pages);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load preview data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 3 && importJobId && previewData.length === 0) {
|
||||
loadPreviewData(1);
|
||||
}
|
||||
}, [currentStep, importJobId]);
|
||||
|
||||
const handleStatusOverride = (rowNum, status) => {
|
||||
setStatusOverrides(prev => ({
|
||||
...prev,
|
||||
[rowNum]: { status }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBulkStatusChange = (status) => {
|
||||
const newOverrides = { ...statusOverrides };
|
||||
selectedRows.forEach(rowNum => {
|
||||
newOverrides[rowNum] = { status };
|
||||
});
|
||||
setStatusOverrides(newOverrides);
|
||||
toast.success(`Updated ${selectedRows.size} users to ${status}`);
|
||||
};
|
||||
|
||||
const toggleRowSelection = (rowNum) => {
|
||||
setSelectedRows(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(rowNum)) {
|
||||
newSet.delete(rowNum);
|
||||
} else {
|
||||
newSet.add(rowNum);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedRows.size === previewData.length) {
|
||||
setSelectedRows(new Set());
|
||||
} else {
|
||||
setSelectedRows(new Set(previewData.map(row => row.row_number)));
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 5: Execute Import
|
||||
// ============================================================================
|
||||
|
||||
const handleExecuteImport = async () => {
|
||||
setImporting(true);
|
||||
setCurrentStep(5);
|
||||
|
||||
try {
|
||||
// Start import
|
||||
const response = await api.post(`/admin/import/${importJobId}/execute`, {
|
||||
overrides: statusOverrides,
|
||||
options: {
|
||||
send_password_emails: true,
|
||||
skip_errors: true
|
||||
}
|
||||
});
|
||||
|
||||
setImportResults(response.data);
|
||||
toast.success(`Import completed: ${response.data.successful_rows} users imported`);
|
||||
|
||||
// Move to results step
|
||||
setCurrentStep(6);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Import failed');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for import progress
|
||||
useEffect(() => {
|
||||
if (currentStep === 5 && importing && importJobId) {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await api.get(`/admin/import/${importJobId}/status`);
|
||||
setImportProgress(response.data.progress_percent);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch import status:', error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [currentStep, importing, importJobId]);
|
||||
|
||||
// ============================================================================
|
||||
// Step 6: Rollback
|
||||
// ============================================================================
|
||||
|
||||
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
const handleRollback = async () => {
|
||||
try {
|
||||
await api.post(`/admin/import/${importJobId}/rollback`, { confirm: true });
|
||||
toast.success(`Rolled back ${importResults.successful_rows} users`);
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Rollback failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadErrors = async () => {
|
||||
try {
|
||||
const response = await api.get(`/admin/import/${importJobId}/errors/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `import_errors_${importJobId}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
toast.success('Error report downloaded');
|
||||
} catch (error) {
|
||||
toast.error('Failed to download error report');
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Status Badge Component
|
||||
// ============================================================================
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
const colors = {
|
||||
active: 'bg-green-100 text-green-800 border-green-300',
|
||||
pre_validated: 'bg-blue-100 text-blue-800 border-blue-300',
|
||||
payment_pending: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
inactive: 'bg-gray-100 text-gray-800 border-gray-300'
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colors[status] || 'bg-gray-100 text-gray-800'}>
|
||||
{status.replace('_', ' ')}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Render Step Content
|
||||
// ============================================================================
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <Step1Upload />;
|
||||
case 2:
|
||||
return <Step2FieldMapping />;
|
||||
case 3:
|
||||
return <Step3ReviewStatus />;
|
||||
case 4:
|
||||
return <Step4Preview />;
|
||||
case 5:
|
||||
return <Step5Execute />;
|
||||
case 6:
|
||||
return <Step6Results />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 1: Upload CSV
|
||||
// ============================================================================
|
||||
|
||||
const Step1Upload = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Upload WordPress CSV Export</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
Select the WordPress user export CSV file. The file will be analyzed for data quality issues.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 border-2 border-dashed border-[var(--neutral-800)] bg-[var(--lavender-400)]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Upload className="h-12 w-12 text-brand-purple " />
|
||||
<div className="text-center">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-brand-purple mt-2">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{uploadedFile && !analysisResult && (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="w-full bg-brand-purple hover:bg-[var(--purple-ink)]"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Analyzing CSV...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload and Analyze
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{analysisResult && (
|
||||
<Card className="p-6 bg-green-50 border-green-200">
|
||||
<h4 className="font-semibold text-green-900 mb-4">Analysis Complete</h4>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-green-700">Total Rows</p>
|
||||
<p className="text-2xl font-semibold text-green-900">{analysisResult.total_rows}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-green-700">Valid Rows</p>
|
||||
<p className="text-2xl font-semibold text-green-900">{analysisResult.valid_rows}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-yellow-700">Warnings</p>
|
||||
<p className="text-2xl font-semibold text-yellow-900">{analysisResult.warnings}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-red-700">Errors</p>
|
||||
<p className="text-2xl font-semibold text-red-900">{analysisResult.errors}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysisResult.data_quality && (
|
||||
<div className="mt-4 pt-4 border-t border-green-300">
|
||||
<h5 className="text-sm font-semibold text-green-900 mb-2">Data Quality Issues:</h5>
|
||||
<ul className="text-sm text-green-800 space-y-1">
|
||||
{analysisResult.data_quality.invalid_dob > 0 && (
|
||||
<li>• {analysisResult.data_quality.invalid_dob} invalid dates of birth</li>
|
||||
)}
|
||||
{analysisResult.data_quality.missing_phone > 0 && (
|
||||
<li>• {analysisResult.data_quality.missing_phone} missing phone numbers</li>
|
||||
)}
|
||||
{analysisResult.data_quality.duplicate_email > 0 && (
|
||||
<li>• {analysisResult.data_quality.duplicate_email} duplicate emails</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Step 2: Field Mapping
|
||||
// ============================================================================
|
||||
|
||||
const Step2FieldMapping = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Field Mapping</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
WordPress fields have been automatically mapped to LOAF platform fields.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>WordPress Field</TableHead>
|
||||
<TableHead>→</TableHead>
|
||||
<TableHead>LOAF Field</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">user_email</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">email</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">first_name</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">first_name</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">last_name</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">last_name</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">cell_phone</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">phone</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">wp_capabilities</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">role + status</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Alert className="bg-blue-50 border-blue-200">
|
||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription className="text-blue-800">
|
||||
WordPress roles will be automatically converted:
|
||||
<ul className="mt-2 ml-4 space-y-1">
|
||||
<li>• <code>loaf_admin</code> → admin (active)</li>
|
||||
<li>• <code>loaf_treasure</code> → finance (active)</li>
|
||||
<li>• <code>administrator</code> → superadmin (active)</li>
|
||||
<li>• <code>pms_subscription_plan_63</code> → member</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Step 3: Review & Adjust Status (KEY FEATURE)
|
||||
// ============================================================================
|
||||
|
||||
const Step3ReviewStatus = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Review & Adjust User Status</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
Review suggested status mappings and override as needed before import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bulk edit toolbar */}
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === previewData.length && previewData.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
<span className="text-sm text-brand-purple font-medium">
|
||||
{selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'}
|
||||
</span>
|
||||
{selectedRows.size > 0 && (
|
||||
<Select onValueChange={handleBulkStatusChange}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Change status to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-purple " />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[var(--lavender-400)]">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox checked={false} />
|
||||
</TableHead>
|
||||
<TableHead>Row</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>WP Role</TableHead>
|
||||
<TableHead>Suggested Status</TableHead>
|
||||
<TableHead>Override Status</TableHead>
|
||||
<TableHead>Issues</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewData.map((row) => (
|
||||
<TableRow key={row.row_number}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.has(row.row_number)}
|
||||
onCheckedChange={() => toggleRowSelection(row.row_number)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.row_number}</TableCell>
|
||||
<TableCell className="text-sm">{row.email}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{row.first_name} {row.last_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]">
|
||||
{row.wordpress_roles?.join(', ') || 'N/A'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={row.suggested_status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={statusOverrides[row.row_number]?.status || row.suggested_status}
|
||||
onValueChange={(value) => handleStatusOverride(row.row_number, value)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.warnings?.map((warning, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="outline"
|
||||
className="text-orange-600 border-orange-300 mr-1"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{warning}
|
||||
</Badge>
|
||||
))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-brand-purple ">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadPreviewData(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadPreviewData(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Step 4: Preview
|
||||
// ============================================================================
|
||||
|
||||
const Step4Preview = () => {
|
||||
const overrideCount = Object.keys(statusOverrides).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Preview</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
Review the final import settings before execution.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-brand-purple ">Total Users</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.total_rows}</p>
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-brand-purple ">Status Overrides</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{overrideCount}</p>
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-brand-purple ">Expected Imports</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.valid_rows}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] mb-4">Import Options</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm text-brand-purple ">Send password reset emails to all imported users</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm text-brand-purple ">Skip rows with errors and continue import</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm text-brand-purple ">Full rollback capability available after import</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{overrideCount > 0 && (
|
||||
<Alert className="bg-yellow-50 border-yellow-200">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||
<AlertDescription className="text-yellow-800">
|
||||
You have overridden {overrideCount} user status{overrideCount > 1 ? 'es' : ''}.
|
||||
These will be applied during import.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 5: Execute
|
||||
// ============================================================================
|
||||
|
||||
const Step5Execute = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
||||
{importing ? 'Import in Progress...' : 'Ready to Import'}
|
||||
</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
{importing
|
||||
? 'Please wait while users are imported. This may take a few minutes.'
|
||||
: 'Click "Start Import" to begin importing users.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{importing && (
|
||||
<div className="space-y-4">
|
||||
<Progress value={importProgress} className="w-full" />
|
||||
<p className="text-center text-sm text-brand-purple ">
|
||||
{importProgress.toFixed(1)}% complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!importing && !importResults && (
|
||||
<Button
|
||||
onClick={handleExecuteImport}
|
||||
className="w-full bg-brand-purple hover:bg-[var(--purple-ink)] py-6 text-lg"
|
||||
>
|
||||
<Play className="mr-2 h-5 w-5" />
|
||||
Start Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Step 6: Results & Rollback
|
||||
// ============================================================================
|
||||
|
||||
const Step6Results = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Complete</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
Review the import results and download error reports if needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Card className="p-6 bg-green-50 border-green-200">
|
||||
<p className="text-sm text-green-700">Successful Imports</p>
|
||||
<p className="text-4xl font-semibold text-green-900">{importResults?.successful_rows || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-red-50 border-red-200">
|
||||
<p className="text-sm text-red-700">Failed Imports</p>
|
||||
<p className="text-4xl font-semibold text-red-900">{importResults?.failed_rows || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-blue-50 border-blue-200">
|
||||
<p className="text-sm text-blue-700">Password Emails Sent</p>
|
||||
<p className="text-4xl font-semibold text-blue-900">{importResults?.password_emails_queued || 0}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-4 justify-between flex-wrap">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{importResults?.failed_rows > 0 && (
|
||||
<Button onClick={handleDownloadErrors} variant="outline">
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Download Error Report
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
View Imported Members
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rollback button (prominent, red) */}
|
||||
{importResults?.successful_rows > 0 && (
|
||||
<Button
|
||||
onClick={() => setRollbackConfirmOpen(true)}
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Rollback Import ({importResults.successful_rows} users)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rollback confirmation dialog */}
|
||||
<Dialog open={rollbackConfirmOpen} onOpenChange={setRollbackConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-3 bg-red-100 rounded-full">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]">
|
||||
Confirm Rollback
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-brand-purple ">
|
||||
This will permanently delete{' '}
|
||||
<strong>{importResults?.successful_rows} users</strong> that were imported.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 my-4">
|
||||
<p className="text-sm text-red-800 font-medium mb-2">
|
||||
Type "DELETE {importResults?.successful_rows} USERS" to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
className="mt-2"
|
||||
placeholder={`DELETE ${importResults?.successful_rows} USERS`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRollbackConfirmOpen(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRollback}
|
||||
disabled={confirmText !== `DELETE ${importResults?.successful_rows} USERS`}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Yes, Delete {importResults?.successful_rows} Users
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Main Render
|
||||
// ============================================================================
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]">
|
||||
WordPress Import Wizard
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-brand-purple ">
|
||||
Import WordPress users with interactive status review and full rollback capability
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-6 px-4">
|
||||
{steps.map((step, index) => {
|
||||
const StepIcon = step.icon;
|
||||
const isCompleted = currentStep > step.number;
|
||||
const isCurrent = currentStep === step.number;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.number}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center
|
||||
${isCurrent ? 'bg-brand-purple text-white' : ''}
|
||||
${isCompleted ? 'bg-green-600 text-white' : ''}
|
||||
${!isCurrent && !isCompleted ? 'bg-gray-200 text-gray-600' : ''}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<StepIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-xs mt-1 ${isCurrent ? 'font-semibold text-[var(--purple-ink)]' : 'text-gray-600'}`}>
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 ${isCompleted ? 'bg-green-600' : 'bg-gray-300'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="py-6">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Navigation footer */}
|
||||
<DialogFooter className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={currentStep === 1 || importing}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{currentStep < 5 && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="bg-brand-purple hover:bg-[var(--purple-ink)]"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentStep === 6 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
}}
|
||||
className="bg-brand-purple hover:bg-[var(--purple-ink)]"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
<div className="space-y-8">
|
||||
{/* Personal Information */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Personal Information
|
||||
</h2>
|
||||
|
||||
@@ -40,7 +40,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.first_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="first-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.last_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="last-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="phone-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.date_of_birth}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="dob-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.address}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
|
||||
className="h-14 rounded-xl border-2 border-var[(--neutral-300)] focus:border-[var(--orange-soft)]"
|
||||
data-testid="address-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.city}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="city-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.state}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="state-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -136,7 +136,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.zipcode}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="zipcode-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* How Did You Hear About Us */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
How Did You Hear About Us? *
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
@@ -167,7 +167,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* Partner Information */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Partner Information (Optional)
|
||||
</h2>
|
||||
|
||||
@@ -179,7 +179,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
name="partner_first_name"
|
||||
value={formData.partner_first_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="partner-first-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -190,7 +190,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
name="partner_last_name"
|
||||
value={formData.partner_last_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="partner-last-name-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -33,10 +33,10 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
<div className="space-y-8">
|
||||
{/* Newsletter Publication Preferences */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Newsletter Publication Preferences *
|
||||
</h2>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Please check what information may be published in LOAF Newsletter
|
||||
</p>
|
||||
|
||||
@@ -97,7 +97,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* Referral */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Referral
|
||||
</h2>
|
||||
<div>
|
||||
@@ -110,10 +110,10 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
value={formData.referred_by_member_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter member name or email"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="referral-input"
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If referred by a current member, you may skip the event attendance requirement.
|
||||
</p>
|
||||
</div>
|
||||
@@ -121,10 +121,10 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* Volunteer Interests */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Volunteer Interests (Optional)
|
||||
</h2>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
I may at some time be interested in volunteering with LOAF in the following ways (training is provided)
|
||||
</p>
|
||||
|
||||
@@ -158,7 +158,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
I am requesting for scholarship
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Scholarship information is kept confidential
|
||||
</p>
|
||||
|
||||
@@ -174,7 +174,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
onChange={handleInputChange}
|
||||
placeholder="Tell us why you're requesting a scholarship..."
|
||||
rows={4}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -23,11 +23,11 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Directory
|
||||
</h2>
|
||||
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Would you like to be displayed on our private members directory? (optional and you can change the answer later)
|
||||
</p>
|
||||
|
||||
@@ -37,8 +37,8 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
className={`
|
||||
p-4 rounded-xl border-2 cursor-pointer transition-all
|
||||
${formData.show_in_directory
|
||||
? 'border-[#ff9e77] bg-[#ff9e77]/5'
|
||||
: 'border-[#ddd8eb] hover:border-[#664fa3]'
|
||||
? 'border-[var(--orange-light)] bg-[var(--orange-light)]/5'
|
||||
: 'border-[var(--neutral-800)] hover:border-brand-purple '
|
||||
}
|
||||
`}
|
||||
onClick={() => setFormData(prev => ({ ...prev, show_in_directory: true }))}
|
||||
@@ -46,13 +46,13 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`
|
||||
w-5 h-5 rounded-full border-2 flex items-center justify-center
|
||||
${formData.show_in_directory ? 'border-[#ff9e77]' : 'border-[#ddd8eb]'}
|
||||
${formData.show_in_directory ? 'border-[var(--orange-light)]' : 'border-[var(--neutral-800)]'}
|
||||
`}>
|
||||
{formData.show_in_directory && (
|
||||
<div className="w-3 h-3 rounded-full bg-[#ff9e77]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[var(--orange-light)]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Yes, include me in the Members Directory
|
||||
</span>
|
||||
</div>
|
||||
@@ -62,8 +62,8 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
className={`
|
||||
p-4 rounded-xl border-2 cursor-pointer transition-all
|
||||
${!formData.show_in_directory
|
||||
? 'border-[#ff9e77] bg-[#ff9e77]/5'
|
||||
: 'border-[#ddd8eb] hover:border-[#664fa3]'
|
||||
? 'border-[var(--orange-light)] bg-[var(--orange-light)]/5'
|
||||
: 'border-[var(--neutral-800)] hover:border-brand-purple '
|
||||
}
|
||||
`}
|
||||
onClick={() => setFormData(prev => ({ ...prev, show_in_directory: false }))}
|
||||
@@ -71,13 +71,13 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`
|
||||
w-5 h-5 rounded-full border-2 flex items-center justify-center
|
||||
${!formData.show_in_directory ? 'border-[#ff9e77]' : 'border-[#ddd8eb]'}
|
||||
${!formData.show_in_directory ? 'border-[var(--orange-light)]' : 'border-[var(--neutral-800)]'}
|
||||
`}>
|
||||
{!formData.show_in_directory && (
|
||||
<div className="w-3 h-3 rounded-full bg-[#ff9e77]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[var(--orange-light)]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No, don't include me in the Members Directory
|
||||
</span>
|
||||
</div>
|
||||
@@ -87,8 +87,8 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* Conditional Directory Fields */}
|
||||
{formData.show_in_directory && (
|
||||
<div className="space-y-4 mt-6 p-6 bg-white rounded-xl border border-[#ddd8eb]">
|
||||
<p className="text-[#664fa3] text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="space-y-4 mt-6 p-6 bg-background rounded-xl border border-[var(--neutral-800)]">
|
||||
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Below, choose what information you would like include in the Members Only Directory.
|
||||
(If you ever want to update this information, remember the Directory Section and Account Section are separate)
|
||||
</p>
|
||||
@@ -101,7 +101,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
type="email"
|
||||
value={formData.directory_email}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
onChange={handleInputChange}
|
||||
placeholder="Tell other members about yourself..."
|
||||
rows={4}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
name="directory_address"
|
||||
value={formData.directory_address}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -137,7 +137,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
type="tel"
|
||||
value={formData.directory_phone}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
type="date"
|
||||
value={formData.directory_dob}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +162,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
name="directory_partner_name"
|
||||
value={formData.directory_partner_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,13 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Account Credentials
|
||||
</h2>
|
||||
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your email is also your username that you can use to login.
|
||||
Please note you can only login after your application is approved.
|
||||
Please note you can only login after your application is validated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="your.email@example.com"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="email-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -43,10 +43,10 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="At least 6 characters"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="password-input"
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Must be at least 6 characters long
|
||||
</p>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Re-enter your password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="confirm-password-input"
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
@@ -69,6 +69,43 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms of Service Acceptance */}
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-lg border border-[var(--neutral-800)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="accepts_tos"
|
||||
name="accepts_tos"
|
||||
checked={formData.accepts_tos || false}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 w-4 h-4 text-brand-purple border-gray-300 rounded focus:ring-brand-purple "
|
||||
required
|
||||
data-testid="tos-checkbox"
|
||||
/>
|
||||
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
I agree to the{' '}
|
||||
<a
|
||||
href="/become-a-member/terms-of-service"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
{' '}and{' '}
|
||||
<a
|
||||
href="become-a-member/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,17 +20,17 @@ const RegistrationStepIndicator = ({ currentStep, totalSteps = 4 }) => {
|
||||
w-12 h-12 rounded-full flex items-center justify-center font-semibold text-lg
|
||||
transition-all duration-300
|
||||
${currentStep === step.number
|
||||
? 'bg-[#ff9e77] text-white scale-110 shadow-lg'
|
||||
? 'bg-[var(--orange-light)] text-white scale-110 shadow-lg'
|
||||
: currentStep > step.number
|
||||
? 'bg-[#81B29A] text-white'
|
||||
: 'bg-[#ddd8eb] text-[#664fa3]'
|
||||
? 'bg-[var(--green-light)] text-white'
|
||||
: 'bg-[var(--neutral-800)] text-brand-purple '
|
||||
}
|
||||
`}>
|
||||
{currentStep > step.number ? '✓' : step.number}
|
||||
</div>
|
||||
<span className={`
|
||||
text-sm mt-2 font-medium transition-colors
|
||||
${currentStep === step.number ? 'text-[#ff9e77]' : 'text-[#664fa3]'}
|
||||
${currentStep === step.number ? 'text-[var(--orange-light)]' : 'text-brand-purple '}
|
||||
`} style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{step.title}
|
||||
</span>
|
||||
@@ -38,11 +38,11 @@ const RegistrationStepIndicator = ({ currentStep, totalSteps = 4 }) => {
|
||||
|
||||
{/* Connecting Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex-1 h-1 mx-2 relative -top-6 bg-[#ddd8eb]">
|
||||
<div className="flex-1 h-1 mx-2 relative -top-6 bg-[var(--neutral-800)]">
|
||||
<div
|
||||
className={`
|
||||
h-full transition-all duration-500
|
||||
${currentStep > step.number ? 'bg-[#81B29A] w-full' : 'bg-transparent w-0'}
|
||||
${currentStep > step.number ? 'bg-[var(--green-light)] w-full' : 'bg-transparent w-0'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
@@ -52,8 +52,8 @@ const RegistrationStepIndicator = ({ currentStep, totalSteps = 4 }) => {
|
||||
</div>
|
||||
|
||||
{/* Step Counter */}
|
||||
<p className="text-center text-[#664fa3] mt-6 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Step <span className="font-semibold text-[#ff9e77]">{currentStep}</span> of {totalSteps}
|
||||
<p className="text-center text-brand-purple mt-6 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Step <span className="font-semibold text-[var(--orange-light)]">{currentStep}</span> of {totalSteps}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
@@ -15,20 +15,32 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
green:
|
||||
"border-transparent bg-[var(--green-forest)] text-white hover:bg-[var(--green-fern)]",
|
||||
orange:
|
||||
"border-transparent bg-orange-500 text-white hover:bg-orange-500/80",
|
||||
orange2:
|
||||
"border-transparent bg-orange-100 text-orange-700 hover:bg-orange-100/80",
|
||||
pink: "border-transparent bg-[var(--pink-500)] text-white hover:bg-[var(--pink-500)]/80",
|
||||
red: "border-transparent bg-red-100 text-red-700 hover:bg-red-100/80",
|
||||
red2: "border-transparent bg-red-500 text-white hover:bg-red-500/80",
|
||||
gray: "border-transparent bg-gray-200 text-gray-700 hover:bg-gray-200/80",
|
||||
gray2: "border-transparent bg-gray-400 text-white hover:bg-gray-400/80",
|
||||
gray3:
|
||||
"border-transparent bg-gray-300 text-gray-600 hover:bg-gray-300/80",
|
||||
purple: "bg-light-lavender",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}) {
|
||||
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
const buttonVariants = cva("btn", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
default: "btn-primary",
|
||||
secondary: "btn-secondary",
|
||||
ghost: "btn-ghost",
|
||||
outline: "btn-outline",
|
||||
"outline-destructive": "btn-outline-destructive",
|
||||
accent: "btn-accent",
|
||||
destructive: "btn-destructive",
|
||||
link: "btn-link",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
default: "btn-md",
|
||||
sm: "btn-sm",
|
||||
lg: "btn-lg",
|
||||
icon: "btn-icon",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Button = React.forwardRef(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,50 +1,65 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
const CommandList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn("max-h-[300px] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden", className)}
|
||||
{...props} />
|
||||
))
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
|
||||
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, .
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-brand-light-lavender data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@@ -50,7 +50,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
@@ -63,7 +63,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref)
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-brand-light-lavender focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PasswordInput = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false)
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -19,7 +19,7 @@ const PasswordInput = React.forwardRef(({ className, ...props }, ref) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#6B708D] hover:text-[#3D405B] transition-colors focus:outline-none"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--slate-muted)] hover:text-[var(--slate-dark)] transition-colors focus:outline-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
@@ -29,8 +29,8 @@ const PasswordInput = React.forwardRef(({ className, ...props }, ref) => {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
PasswordInput.displayName = "PasswordInput"
|
||||
);
|
||||
});
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
|
||||
export { PasswordInput }
|
||||
export { PasswordInput };
|
||||
|
||||
@@ -52,7 +52,7 @@ const SelectContent = React.forwardRef(({ className, children, position = "poppe
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden scrollbar-dashboard scrollbar-x-dashboard rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-full items-center justify-center rounded-lg gap-6 p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
"inline-flex items-center justify-center whitespace-nowrap hover:bg-[var(--lavender-300)] border-2 border-brand-purple rounded-2xl px-3 py-1 text-brand-purple text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background data-[state=active]:border-foreground data-[state=active]:shadow dark:data-[state=active]:bg-brand-light-lavender dark:data-[state=active]:text-background dark:border-brand-light-lavender dark:text-brand-light-lavender",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
@@ -34,8 +36,9 @@ const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import api from '../utils/api';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
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
|
||||
logger.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 }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
@@ -20,9 +30,13 @@ export const AuthProvider = ({ children }) => {
|
||||
});
|
||||
setUser(response.data);
|
||||
setToken(storedToken);
|
||||
|
||||
// Fetch user permissions
|
||||
await fetchPermissions(storedToken);
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setPermissions([]);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -30,19 +44,118 @@ export const AuthProvider = ({ children }) => {
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const fetchPermissions = async (authToken) => {
|
||||
try {
|
||||
const tokenToUse = authToken || token || localStorage.getItem('token');
|
||||
if (!tokenToUse) {
|
||||
setPermissions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get(`${API_URL}/api/auth/permissions`, {
|
||||
headers: { Authorization: `Bearer ${tokenToUse}` }
|
||||
});
|
||||
setPermissions(response.data.permissions || []);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch permissions:', error);
|
||||
setPermissions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
const response = await axios.post(`${API_URL}/api/auth/login`, { email, password });
|
||||
try {
|
||||
logger.log('[AuthContext] Starting login request...', {
|
||||
API_URL: API_URL,
|
||||
envBackendUrl: process.env.REACT_APP_BACKEND_URL,
|
||||
fullUrl: `${API_URL}/api/auth/login`
|
||||
});
|
||||
|
||||
// Use api instance for retry logic
|
||||
const response = await api.post(
|
||||
'/auth/login',
|
||||
{ email, password },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.log('[AuthContext] Login response received:', {
|
||||
status: response.status,
|
||||
hasToken: !!response.data?.access_token,
|
||||
hasUser: !!response.data?.user
|
||||
});
|
||||
|
||||
const { access_token, user: userData } = response.data;
|
||||
|
||||
if (!access_token || !userData) {
|
||||
throw new Error('Invalid response from server - missing token or user data');
|
||||
}
|
||||
|
||||
// Store token FIRST and verify it was stored
|
||||
localStorage.setItem('token', access_token);
|
||||
const storedToken = localStorage.getItem('token');
|
||||
if (storedToken !== access_token) {
|
||||
throw new Error('Failed to store token in localStorage');
|
||||
}
|
||||
logger.log('[AuthContext] Token stored and verified in localStorage');
|
||||
|
||||
// Update state in correct order
|
||||
setToken(access_token);
|
||||
setUser(userData);
|
||||
logger.log('[AuthContext] User state updated:', {
|
||||
email: userData.email,
|
||||
role: userData.role
|
||||
});
|
||||
|
||||
// Fetch permissions immediately and WAIT for it (but don't fail login if it fails)
|
||||
try {
|
||||
logger.log('[AuthContext] Fetching permissions...');
|
||||
await fetchPermissions(access_token);
|
||||
logger.log('[AuthContext] Permissions fetched successfully');
|
||||
} catch (permError) {
|
||||
logger.error('[AuthContext] Failed to fetch permissions (non-critical):', {
|
||||
message: permError.message,
|
||||
response: permError.response?.data,
|
||||
status: permError.response?.status
|
||||
});
|
||||
// Set empty permissions array so hasPermission doesn't break
|
||||
setPermissions([]);
|
||||
// Don't throw - login succeeded even if permissions failed
|
||||
}
|
||||
|
||||
return userData;
|
||||
} catch (error) {
|
||||
// Enhanced error logging
|
||||
logger.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
|
||||
}
|
||||
});
|
||||
|
||||
// Clear any partial state
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setPermissions([]);
|
||||
|
||||
// Re-throw to let Login component handle the error
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setPermissions([]);
|
||||
};
|
||||
|
||||
const register = async (userData) => {
|
||||
@@ -62,7 +175,7 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user:', error);
|
||||
logger.error('Failed to refresh user:', error);
|
||||
// If token expired, logout
|
||||
if (error.response?.status === 401) {
|
||||
logout();
|
||||
@@ -124,10 +237,18 @@ export const AuthProvider = ({ children }) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const hasPermission = (permissionCode) => {
|
||||
if (!user) return false;
|
||||
// Superadmin always has all permissions
|
||||
if (user.role === 'superadmin') return true;
|
||||
return permissions.includes(permissionCode);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
token,
|
||||
permissions,
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
@@ -136,6 +257,7 @@ export const AuthProvider = ({ children }) => {
|
||||
resetPassword,
|
||||
changePassword,
|
||||
resendVerificationEmail,
|
||||
hasPermission,
|
||||
loading
|
||||
}}>
|
||||
{children}
|
||||
|
||||
125
src/index.css
125
src/index.css
@@ -1,115 +1,10 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 280 47% 27%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 280 47% 27%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 280 47% 27%;
|
||||
--primary: 280 47% 27%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 268 33% 89%;
|
||||
--secondary-foreground: 280 47% 27%;
|
||||
--muted: 268 43% 95%;
|
||||
--muted-foreground: 268 35% 47%;
|
||||
--accent: 17 100% 73%;
|
||||
--accent-foreground: 280 47% 27%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 268 33% 89%;
|
||||
--input: 268 33% 89%;
|
||||
--ring: 268 35% 47%;
|
||||
--chart-1: 268 36% 46%;
|
||||
--chart-2: 17 100% 73%;
|
||||
--chart-3: 268 33% 89%;
|
||||
--chart-4: 280 44% 29%;
|
||||
--chart-5: 268 35% 47%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
[data-debug-wrapper="true"] {
|
||||
display: contents !important;
|
||||
}
|
||||
|
||||
[data-debug-wrapper="true"] > * {
|
||||
margin-left: inherit;
|
||||
margin-right: inherit;
|
||||
margin-top: inherit;
|
||||
margin-bottom: inherit;
|
||||
padding-left: inherit;
|
||||
padding-right: inherit;
|
||||
padding-top: inherit;
|
||||
padding-bottom: inherit;
|
||||
column-gap: inherit;
|
||||
row-gap: inherit;
|
||||
gap: inherit;
|
||||
border-left-width: inherit;
|
||||
border-right-width: inherit;
|
||||
border-top-width: inherit;
|
||||
border-bottom-width: inherit;
|
||||
border-left-style: inherit;
|
||||
border-right-style: inherit;
|
||||
border-top-style: inherit;
|
||||
border-bottom-style: inherit;
|
||||
border-left-color: inherit;
|
||||
border-right-color: inherit;
|
||||
border-top-color: inherit;
|
||||
border-bottom-color: inherit;
|
||||
}
|
||||
}
|
||||
@import "./styles/App.css";
|
||||
@import "./styles/theme.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/utilities.css";
|
||||
/*
|
||||
=========================
|
||||
End of File
|
||||
========================
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import '@fontsource/fraunces/600.css';
|
||||
import '@fontsource/dm-sans/400.css';
|
||||
import '@fontsource/dm-sans/700.css';
|
||||
@@ -9,6 +10,13 @@ import App from './App';
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
storageKey="admin-theme"
|
||||
>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import AdminSidebar from '../components/AdminSidebar';
|
||||
|
||||
const AdminLayout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Initialize sidebar state from localStorage
|
||||
useEffect(() => {
|
||||
@@ -43,7 +46,7 @@ const AdminLayout = ({ children }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
|
||||
{/* Sidebar */}
|
||||
<AdminSidebar
|
||||
isOpen={sidebarOpen}
|
||||
@@ -60,7 +63,7 @@ const AdminLayout = ({ children }) => {
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<main className="flex-1 overflow-y-auto scrollbar-dashboard">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
544
src/pages/AcceptInvitation.js
Normal file
544
src/pages/AcceptInvitation.js
Normal file
@@ -0,0 +1,544 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } 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 { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Alert, AlertDescription } from '../components/ui/alert';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Mail, Shield, CheckCircle, XCircle, Calendar } from 'lucide-react';
|
||||
|
||||
const AcceptInvitation = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
const [token, setToken] = useState(null);
|
||||
const [invitation, setInvitation] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successUser, setSuccessUser] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipcode: '',
|
||||
date_of_birth: ''
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const invitationToken = searchParams.get('token');
|
||||
if (!invitationToken) {
|
||||
setError('Invalid invitation link. No token provided.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(invitationToken);
|
||||
verifyInvitation(invitationToken);
|
||||
}, [searchParams]);
|
||||
|
||||
const verifyInvitation = async (invitationToken) => {
|
||||
try {
|
||||
const response = await api.get(`/invitations/verify/${invitationToken}`);
|
||||
setInvitation(response.data);
|
||||
|
||||
// Pre-fill form with invitation data
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
first_name: response.data.first_name || '',
|
||||
last_name: response.data.last_name || '',
|
||||
phone: response.data.phone || ''
|
||||
}));
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setError(error.response?.data?.detail || 'Invalid or expired invitation token');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (formErrors[field]) {
|
||||
setFormErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.password || formData.password.length < 8) {
|
||||
newErrors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
if (!formData.first_name) {
|
||||
newErrors.first_name = 'First name is required';
|
||||
}
|
||||
|
||||
if (!formData.last_name) {
|
||||
newErrors.last_name = 'Last name is required';
|
||||
}
|
||||
|
||||
if (!formData.phone) {
|
||||
newErrors.phone = 'Phone is required';
|
||||
}
|
||||
|
||||
setFormErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
// Prepare payload
|
||||
const payload = {
|
||||
token,
|
||||
password: formData.password,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
phone: formData.phone
|
||||
};
|
||||
|
||||
// Add optional fields if provided
|
||||
if (formData.address) payload.address = formData.address;
|
||||
if (formData.city) payload.city = formData.city;
|
||||
if (formData.state) payload.state = formData.state;
|
||||
if (formData.zipcode) payload.zipcode = formData.zipcode;
|
||||
if (formData.date_of_birth) payload.date_of_birth = formData.date_of_birth;
|
||||
|
||||
// Accept invitation
|
||||
const response = await api.post('/invitations/accept', payload);
|
||||
|
||||
// Auto-login with returned token
|
||||
const { access_token, user } = response.data;
|
||||
localStorage.setItem('token', access_token);
|
||||
|
||||
// Call login to update auth context
|
||||
if (login) {
|
||||
await login(invitation.email, formData.password);
|
||||
}
|
||||
|
||||
// Show success state
|
||||
setSuccessUser(user);
|
||||
setSuccess(true);
|
||||
|
||||
// Auto-redirect after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (user.role === 'admin' || user.role === 'superadmin') {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
const config = {
|
||||
superadmin: { label: 'Superadmin', className: 'bg-brand-purple text-white' },
|
||||
admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' },
|
||||
member: { label: 'Member', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' }
|
||||
};
|
||||
|
||||
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
|
||||
return (
|
||||
<Badge className={`${roleConfig.className} px-4 py-2 rounded-full text-sm`}>
|
||||
<Shield className="h-4 w-4 mr-2 inline" />
|
||||
{roleConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center">
|
||||
<Loader2 className="h-12 w-12 text-brand-purple mx-auto mb-4 animate-spin" />
|
||||
<p className="text-lg text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Verifying your invitation...
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center">
|
||||
<XCircle className="h-16 w-16 text-red-500 mx-auto mb-6" />
|
||||
<h1 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Invalid Invitation
|
||||
</h1>
|
||||
<p className="text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate('/login')}
|
||||
className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white"
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
const redirectPath = successUser?.role === 'admin' || successUser?.role === 'superadmin' ? '/admin' : '/dashboard';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center">
|
||||
{/* Success Animation */}
|
||||
<div className="mb-8">
|
||||
<div className="h-24 w-24 mx-auto rounded-full bg-gradient-to-br from-[var(--green-light)] to-[var(--green-fern)] 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-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome to LOAF! 🎉
|
||||
</h1>
|
||||
<p className="text-xl text-brand-purple 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-[var(--neutral-800)] to-[var(--lavender-700)] rounded-xl">
|
||||
<div className="grid md:grid-cols-2 gap-4 text-left">
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Name
|
||||
</p>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{successUser?.first_name} {successUser?.last_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Email
|
||||
</p>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{successUser?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Role
|
||||
</p>
|
||||
<div>{getRoleBadge(successUser?.role)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Status
|
||||
</p>
|
||||
<Badge className="bg-[var(--green-light)] 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-[var(--green-light)] to-[var(--green-fern)] hover:from-[var(--green-fern)] hover:to-[var(--green-sage)] text-white text-lg font-semibold"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] flex items-center justify-center">
|
||||
<Mail className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome to LOAF!
|
||||
</h1>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Complete your profile to accept the invitation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invitation Details */}
|
||||
<div className="mb-8 p-6 bg-gradient-to-r from-[var(--neutral-800)] to-[var(--lavender-700)] rounded-xl">
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Email Address
|
||||
</p>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{invitation?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Role
|
||||
</p>
|
||||
<div>{getRoleBadge(invitation?.role)}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-brand-purple mb-1 flex items-center gap-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Invitation Expires
|
||||
</p>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{invitation?.expires_at ? new Date(invitation.expires_at).toLocaleString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6">
|
||||
{/* Password Fields */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password" className="text-[var(--purple-ink)]">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
{formErrors.password && (
|
||||
<p className="text-sm text-red-500">{formErrors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirmPassword" className="text-[var(--purple-ink)]">
|
||||
Confirm Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Re-enter password"
|
||||
/>
|
||||
{formErrors.confirmPassword && (
|
||||
<p className="text-sm text-red-500">{formErrors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name Fields */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="John"
|
||||
/>
|
||||
{formErrors.first_name && (
|
||||
<p className="text-sm text-red-500">{formErrors.first_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
{formErrors.last_name && (
|
||||
<p className="text-sm text-red-500">{formErrors.last_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
{formErrors.phone && (
|
||||
<p className="text-sm text-red-500">{formErrors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional Fields Section */}
|
||||
{invitation?.role === 'member' && (
|
||||
<>
|
||||
<div className="border-t border-[var(--neutral-800)] pt-6 mt-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Additional Information (Optional)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address" className="text-[var(--purple-ink)]">Address</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City, State, Zipcode */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="city" className="text-[var(--purple-ink)]">City</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="state" className="text-[var(--purple-ink)]">State</Label>
|
||||
<Input
|
||||
id="state"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleChange('state', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="CA"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="zipcode" className="text-[var(--purple-ink)]">Zipcode</Label>
|
||||
<Input
|
||||
id="zipcode"
|
||||
value={formData.zipcode}
|
||||
onChange={(e) => handleChange('zipcode', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="94102"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date_of_birth" className="text-[var(--purple-ink)]">Date of Birth</Label>
|
||||
<Input
|
||||
id="date_of_birth"
|
||||
type="date"
|
||||
value={formData.date_of_birth}
|
||||
onChange={(e) => handleChange('date_of_birth', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full h-14 rounded-xl bg-gradient-to-r from-[var(--green-light)] to-[var(--green-fern)] hover:from-[var(--green-fern)] hover:to-[var(--green-sage)] text-white text-lg font-semibold"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Creating Your Account...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
Accept Invitation & Create Account
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer Note */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
|
||||
>
|
||||
Sign in instead
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcceptInvitation;
|
||||
@@ -14,30 +14,28 @@ const BecomeMember = () => {
|
||||
const imgIconAdminFee5 = `${process.env.PUBLIC_URL}/imgIconAdminFee5.png`;
|
||||
const imgShootingStar = `${process.env.PUBLIC_URL}/imgShootingStar.png`;
|
||||
|
||||
const Arrow = ({ ...props }) => (
|
||||
<div className="flex justify-center mb-2">
|
||||
<ArrowDown className="size-8 text---brand-purple font-bold" strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 relative">
|
||||
<PublicNavbar />
|
||||
|
||||
{/* Decorative shooting star element */}
|
||||
<div className="hidden lg:block absolute left-[88px] top-[974px] w-[195px] h-[1135px] pointer-events-none opacity-50">
|
||||
<img
|
||||
src={imgShootingStar}
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-gray-50 pt-20 pb-24">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<div className="relative bg-gray-50 pt-20 px-6 pb-16">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.96px]"
|
||||
className="text-3xl sm:text-4xl md:text-5xl font-bold text-[var(--purple-deep)] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.96px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Become a Member
|
||||
</h1>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] max-w-2xl mx-auto leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] max-w-2xl mx-auto leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Become a member to receive our monthly newsletter and find out about all the activities LOAF has planned each month. LOAF hosts over 40 social activities each year and occasionally covers the costs for members only
|
||||
@@ -46,7 +44,7 @@ const BecomeMember = () => {
|
||||
</div>
|
||||
|
||||
{/* Annual Administrative Fees Section */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-12 sm:mb-16">
|
||||
<div className="max-w-[1340px] z-10 mx-auto px-6 mb-12 sm:mb-16">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<img
|
||||
@@ -55,15 +53,15 @@ const BecomeMember = () => {
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-[#eeebf4] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-[var(--lavender-200)] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[var(--purple-deep)] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Annual Administrative Fees
|
||||
</h3>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Annual Administrative Fees for all members are $30 per person. These fees help cover general business expenses (website, advertising, e-newsletter).
|
||||
@@ -73,74 +71,80 @@ const BecomeMember = () => {
|
||||
</div>
|
||||
|
||||
{/* Membership Process Section */}
|
||||
<div className="relative bg-gray-50 py-16">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<div className="relative bg-gray-50 py-32 bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] ">
|
||||
{/* Decorative shooting star element */}
|
||||
<div className="hidden lg:block absolute left-0 top-64 w-[195px] h-[1130px] pointer-events-none opacity-50">
|
||||
<img
|
||||
src={imgShootingStar}
|
||||
alt=""
|
||||
className="w-full h-full z-20 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 mb-24 text-center">
|
||||
<h2
|
||||
className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.8px]"
|
||||
className="text-2xl sm:text-3xl md:text-[40px] font-bold text-[var(--purple-deep)] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.8px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Membership Process
|
||||
</h2>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] max-w-2xl mx-auto leading-[1.6]"
|
||||
className="text-base sm:text-lg md:text-xl lg:text-2xl lg:font-semibold font-medium text-[var(--purple-deep)] max-w-2xl mx-auto leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Becoming a member is easy, but for the safety and privacy of our membership, there are a few steps:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Step 1 */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-6 sm:mb-8">
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-2 ">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<div className="w-24 h-24 sm:w-32 z-40 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee2}
|
||||
alt="Step 1 Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-background rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[var(--purple-deep)] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 1: Application & Email Confirmation
|
||||
</h3>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are approved by an admin.
|
||||
Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are validated by an admin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Down Icon */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
||||
</div>
|
||||
<Arrow />
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-6 sm:mb-8">
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-2">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<div className="w-24 h-24 z-40 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee3}
|
||||
alt="Step 2 Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-background rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[var(--purple-deep)] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 2: Attend an event and meet us!
|
||||
</h3>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
You have 3 months to attend a LOAF event and introduce yourself to a board member. If you do not attend an event within 3 months, you will no longer receive the e-newsletter. (This step can be skipped if you have been referred from a current member and list her on your registration form).
|
||||
@@ -150,53 +154,48 @@ const BecomeMember = () => {
|
||||
</div>
|
||||
|
||||
{/* Arrow Down Icon */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<Arrow />
|
||||
{/* Step 3 */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-6 sm:mb-8">
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-2">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<div className="w-24 h-24 z-40 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee4}
|
||||
alt="Step 3 Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-background rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[var(--purple-deep)] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 3: Login and pay the annual fee
|
||||
</h3>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Once we know that you are indeed you, an admin will approve your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.
|
||||
Once we know that you are indeed you, an admin will validate your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Down Icon */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
||||
</div>
|
||||
<Arrow />
|
||||
|
||||
{/* Step 4 - With Gradient Background */}
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-12 sm:mb-16">
|
||||
<div className="max-w-[1340px] mx-auto px-6 mb-2">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<div className="w-24 h-24 z-40 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||
<img
|
||||
src={imgIconAdminFee5}
|
||||
alt="Step 4 Icon"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-gradient-to-r from-[#48286e] to-[#664fa3] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-gradient-to-r from-[var(--purple-deep)] to-[var(--purple-lavender)] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-white mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
@@ -212,19 +211,19 @@ const BecomeMember = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* CTA Section */}
|
||||
<div className="relative bg-gray-50 py-16 ">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row items-center justify-center gap-12 text-center">
|
||||
<h2
|
||||
className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.8px]"
|
||||
className="text-2xl sm:text-3xl md:text-[40px] content-center font-bold text-[var(--purple-deep)] leading-[1.2] tracking-[-0.8px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Ready to Join Us?
|
||||
</h2>
|
||||
<Link to="/register">
|
||||
<Button
|
||||
className="bg-[#664fa3] text-white hover:bg-[#48286e] rounded-[35px] px-6 py-3 sm:px-12 sm:py-5 md:px-16 md:py-6 text-base sm:text-lg font-medium tracking-[-0.09px] h-auto"
|
||||
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-deep)] rounded-[35px] px-6 py-3 sm:px-12 sm:py-5 md:px-16 md:py-6 text-[19px] sm:text-lg font-medium tracking-[-0.09px] leading-5 h-auto"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Register Now!
|
||||
|
||||
@@ -12,97 +12,148 @@ const BoardOfDirectors = () => {
|
||||
];
|
||||
|
||||
const boardMembers = [
|
||||
'Danita Cole',
|
||||
'Roxanne Cherico',
|
||||
'Lucretia Copeland',
|
||||
'Julie Fischer'
|
||||
{ name: 'Danita Cole', title: 'Director' },
|
||||
{ name: 'Roxanne Cherico', title: 'Director' },
|
||||
{ name: 'Lucretia Copeland', title: 'Director' },
|
||||
{ name: 'Julie Fischer', title: 'Director' }
|
||||
];
|
||||
|
||||
|
||||
const DirectorCards = ({ title, members }) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className=" w-full">
|
||||
<div className="mx-auto bg-background rounded-3xl p-10 shadow-lg h-full">
|
||||
{title && (
|
||||
<h2
|
||||
className="text-2xl sm:text-4xl font-bold text-[var(--purple-deep)] text-center mb-8"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className="grid grid-col-span-1 lg:grid-cols-2 gap-5 w-full">
|
||||
{members.map((member, index) => {
|
||||
const { name, title } =
|
||||
typeof member === "string"
|
||||
? { name: member, title: "" }
|
||||
: member;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${name}-${index}`}
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className="bg-[var(--lavender-200)] text-[var(--purple-deep)] text-center px-6 py-5 rounded-3xl border border-white/70 shadow-sm"
|
||||
>
|
||||
<div className="min-h-16">
|
||||
<p className="text-xl font-bold text-[var(--purple-deep)]">
|
||||
{name}
|
||||
</p>
|
||||
|
||||
{title && (
|
||||
<p className="text-xl mt-2 font-semibold">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-white to-[#f1eef9] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
{/* Hero Section with Contact */}
|
||||
<section className="bg-gradient-to-r from-[#664fa3] to-[#48286e] py-8 rounded-2xl mb-12">
|
||||
<main className="bg-gradient-to-b from-[var(--neutral-100:)] to-[var(--neutral-800)] pt-8 sm:pt-10 md:pt-12">
|
||||
{/* Hero Section */}
|
||||
<section className=" pt-16 pb-4 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||
<div className="max-w-5xl mx-auto text-center px-8">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-2xl sm:text-3xl md:text-[40px] leading-[1.2] text-[var(--purple-deep)] font-bold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
LOAF Board of Directors 2025
|
||||
</h1>
|
||||
<p className="text-white text-lg mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{/* Contact Info */}
|
||||
<section className="flex justify-center mt-4 mb-8">
|
||||
<div className=" w-full text-center px-8 justify-center bg-gradient-to-r from-[var(--purple-lavender)] to-[var(--purple-deep)] max-w-5xl mx-6 flex lg:mx-12 py-5 rounded-3xl sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||
|
||||
<p className="text-white text-xl font-bold " style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
For any questions or inquiries please email us at{' '}
|
||||
<a href="mailto:info@loaftx.org" className="text-[#c5b4e3] underline font-bold hover:text-white transition-colors">
|
||||
<a href="mailto:info@loaftx.org" className="text-[var(--neutral-500)] underline font-bold hover:text-white transition-colors">
|
||||
info@loaftx.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Board Members Section */}
|
||||
<section className=' flex lg:flex-row flex-col gap-10 items-stretch mt-8 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20 pb-12'>
|
||||
{/* Officers Grid */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Officers
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{officers.map((officer, index) => (
|
||||
<Card key={index} className="bg-[#eeebf4] p-6 text-center rounded-xl shadow-md hover:shadow-lg transition-shadow">
|
||||
<h3 className="text-xl font-bold text-[#48286e] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{officer.name}
|
||||
</h3>
|
||||
<p className="text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{officer.title}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<DirectorCards title="Officers" members={officers} />
|
||||
|
||||
{/* Board Members Grid */}
|
||||
<section className="py-12 bg-gray-50 rounded-2xl">
|
||||
<div className="max-w-6xl mx-auto px-8">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Board of Directors
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{boardMembers.map((member, index) => (
|
||||
<Card key={index} className="bg-[#eeebf4] p-6 text-center rounded-xl shadow-md hover:shadow-lg transition-shadow">
|
||||
<h3 className="text-xl font-bold text-[#48286e]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{member}
|
||||
</h3>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DirectorCards title="Board Of Directors" members={boardMembers} />
|
||||
|
||||
</section>
|
||||
|
||||
{/* Join the Board Section */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<section className="py-12 bg-background mt-12 ">
|
||||
{/* content containter */}
|
||||
<div className="w-full mx-auto flex flex-col px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||
<h2 className="text-xl mx-auto sm:text-2xl md:text-4xl font-bold text-[var(--purple-deep)] text-center mb-8" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Join the Board of Directors
|
||||
</h2>
|
||||
<p className="text-xl text-[#48286e] text-center mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our elections take place at our December holiday social. Here are some things
|
||||
to know if you are thinking about serving on the Board of Directors.
|
||||
<p className="lg:text-2xl text-md md:text-lg max-w-4xl mx-auto justify-center font-bold text-[var(--purple-deep)] text-center mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our elections take place at our December holiday social. Here are some things to know if you are thinking about serving on the Board of Directors.
|
||||
</p>
|
||||
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg">
|
||||
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{/* card */}
|
||||
<Card className="bg-[var(--lavender-200)] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
|
||||
<ol className="list-decimal list-inside space-y-4 text-lg text-[var(--purple-deep)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li>
|
||||
Nominations are due by November 1. Nomination Form:{' '}
|
||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
|
||||
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
|
||||
className="text-[var(--purple-lavender)] underline hover:text-[var(--purple-deep)] transition-colors">
|
||||
Click Here
|
||||
</a>
|
||||
</li>
|
||||
<li>Nominees must have been a member for at least 1 year and current with their dues.</li>
|
||||
<li>Nominees must have been a member for at least 1 year, however it is possible to be elected prior to 1 year, but start the term on the 1 year anniversary.</li>
|
||||
<li>Officer positions are only available to current directors.</li>
|
||||
<li>Each director shall serve a 2 year term.</li>
|
||||
<li>The time commitment is 1-2 hours per week.</li>
|
||||
<li>The tasks that directors perform depend on individual interests, skills, and time available.</li>
|
||||
<li>Directors must attend Board meetings which are held the second Thursday of each month at 6:30pm via Zoom.</li>
|
||||
<li>We are a fun group, and we would love for you to join us in providing this service for our community.</li>
|
||||
|
||||
<li>Each director shall serve a 2-year term.</li>
|
||||
|
||||
<li>The time commitment is approximately 1–2 hours per week.</li>
|
||||
|
||||
<li>
|
||||
The tasks that directors perform depend on individual interests. Recent
|
||||
tasks include researching how to obtain an extra PO Box key, ordering
|
||||
Welcome Team name tags, taking pictures at events, researching new venues
|
||||
for holiday socials, and monitoring Facebook posts. For more information
|
||||
about director duties, see Article 2 of the bylaws in the Members Only
|
||||
section of the website:
|
||||
<a
|
||||
href="https://loaftx.org/bylaws/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--purple-deep)] underline"
|
||||
>
|
||||
https://loaftx.org/bylaws/
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Directors must attend Board meetings held on the second Thursday of each
|
||||
month at 6:30pm via Zoom.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
We are a fun group, and we would love for you to join us in providing this
|
||||
service for our community.
|
||||
</li>
|
||||
</ol>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -83,19 +83,19 @@ const ChangePasswordRequired = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[#FFEBEE] mb-4">
|
||||
<AlertTriangle className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Password Change Required
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your password was reset by an administrator. Please create a new password to continue.
|
||||
</p>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@ const ChangePasswordRequired = () => {
|
||||
value={formData.currentPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter temporary password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ const ChangePasswordRequired = () => {
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter new password (min. 6 characters)"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -139,15 +139,15 @@ const ChangePasswordRequired = () => {
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Re-enter new password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f1eef9] border-l-4 border-[#664fa3] p-4 rounded-lg">
|
||||
<div className="bg-[var(--lavender-300)] border-l-4 border-brand-purple p-4 rounded-lg">
|
||||
<div className="flex items-start">
|
||||
<Lock className="h-5 w-5 text-[#664fa3] mr-2 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="font-medium text-[#422268] mb-1">Password Requirements:</p>
|
||||
<Lock className="h-5 w-5 text-brand-purple mr-2 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="font-medium text-[var(--purple-ink)] mb-1">Password Requirements:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>At least 6 characters long</li>
|
||||
<li>Both passwords must match</li>
|
||||
@@ -159,17 +159,17 @@ const ChangePasswordRequired = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Changing Password...' : 'Change Password'}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="text-center pt-4 border-t border-[#ddd8eb]">
|
||||
<div className="text-center pt-4 border-t border-[var(--neutral-800)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="text-[#664fa3] hover:text-[#ff9e77] text-sm underline"
|
||||
className="text-brand-purple hover:text-[var(--orange-light)] text-sm underline"
|
||||
>
|
||||
Logout instead
|
||||
</button>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Checkbox } from '../components/ui/checkbox';
|
||||
import { Mail, MapPin, Loader2 } from 'lucide-react';
|
||||
import api from '../utils/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PiMapTrifoldBold } from "react-icons/pi";
|
||||
const ContactUs = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
@@ -96,15 +96,15 @@ const ContactUs = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-[#e8e0f5] to-[#f1eef9] px-6 py-16">
|
||||
<main className="bg-gradient-to-b from-[var(--lavender-100)] to-[var(--lavender-300)] px-6 py-16">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
||||
{/* Contact Form */}
|
||||
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-lg">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-background rounded-2xl">
|
||||
<h1 className="text-2xl sm:text-[28px] leading-5 font-bold text-[var(--purple-deep)] mb-12" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Contact Form
|
||||
</h1>
|
||||
|
||||
@@ -112,7 +112,7 @@ const ContactUs = () => {
|
||||
{/* First Name & Last Name */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="firstName" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="firstName" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -120,13 +120,13 @@ const ContactUs = () => {
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||
className="border-2 border-[var(--neutral-800)] bg-[var(--lavender-800)] focus:border-[var(--purple-lavender)] rounded-full h-12 px-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lastName" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="lastName" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -134,7 +134,7 @@ const ContactUs = () => {
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||
className="border-2 bg-[var(--lavender-800)] border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-full h-12 px-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
@@ -143,7 +143,7 @@ const ContactUs = () => {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="email" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -152,7 +152,7 @@ const ContactUs = () => {
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||
className="border-2 bg-[var(--lavender-800)] border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-full h-12 px-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
@@ -160,7 +160,7 @@ const ContactUs = () => {
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<Label htmlFor="subject" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="subject" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subject <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -168,7 +168,7 @@ const ContactUs = () => {
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||
className="border-2 bg-[var(--lavender-800)] border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-full h-12 px-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
@@ -176,7 +176,7 @@ const ContactUs = () => {
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="message" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Your Message <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
@@ -184,7 +184,7 @@ const ContactUs = () => {
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-2xl min-h-[150px] px-4 py-3 resize-none"
|
||||
className="border-2 bg-[var(--lavender-800)] border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-2xl min-h-[150px] px-4 py-3 resize-none"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
@@ -196,9 +196,9 @@ const ContactUs = () => {
|
||||
id="consent"
|
||||
checked={formData.consent}
|
||||
onCheckedChange={handleConsentChange}
|
||||
className="mt-1 border-2 border-[#ddd8eb] data-[state=checked]:bg-[#664fa3] data-[state=checked]:border-[#664fa3]"
|
||||
className="mt-1 border-2 border-[var(--neutral-800)] data-[state=checked]:bg-[var(--purple-lavender)] data-[state=checked]:border-[var(--purple-lavender)]"
|
||||
/>
|
||||
<Label htmlFor="consent" className="text-[#48286e] text-sm font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Label htmlFor="consent" className="text-[var(--purple-deep)] text-sm font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
I consent to LOAF storing my submitted information so they can respond to my inquiry <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -207,7 +207,7 @@ const ContactUs = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#48286e] text-white rounded-full py-6 text-lg font-semibold disabled:opacity-50"
|
||||
className="w-full bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white rounded-full py-6 text-lg font-semibold disabled:opacity-50"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -225,22 +225,22 @@ const ContactUs = () => {
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-6">
|
||||
{/* Message Card */}
|
||||
<Card className="p-8 bg-gradient-to-r from-[#664fa3] to-[#48286e] rounded-2xl shadow-lg text-white">
|
||||
<p className="text-xl leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Card className="p-8 bg-gradient-to-r from-[var(--purple-lavender)] to-[var(--purple-deep)] rounded-2xl shadow-lg text-white">
|
||||
<p className="text-[28px] font-semibold leading-relaxed" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
If you have questions, or are interested in joining, we would love hearing from you.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Email Card */}
|
||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-6 bg-background rounded-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-[#e8e0f5] rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Mail className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="flex items-center justify-center flex-shrink-0">
|
||||
<Mail className="size-12 text-[var(--purple-lavender)]" />
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="mailto:info@loaftx.org"
|
||||
className="text-[#664fa3] text-xl font-semibold hover:text-[#48286e] transition-colors"
|
||||
className="text-[var(--purple-electric)] text-xl font-semibold hover:text-[var(--purple-deep)] transition-colors"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
info@loaftx.org
|
||||
@@ -250,16 +250,16 @@ const ContactUs = () => {
|
||||
</Card>
|
||||
|
||||
{/* Address Card */}
|
||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-6 bg-background rounded-2xl ">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-[#e8e0f5] rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<MapPin className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="flex items-center justify-center flex-shrink-0">
|
||||
<PiMapTrifoldBold className="size-12 text-[var(--purple-lavender)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#48286e] text-lg font-semibold mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] text-[28px] font-semibold mb-1" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF
|
||||
</p>
|
||||
<p className="text-[#664fa3] text-base leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] text-[28px] font-semibold leading-relaxed" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
P.O. Box 7207<br />
|
||||
Houston, Texas 77248-7207
|
||||
</p>
|
||||
|
||||
@@ -15,9 +15,13 @@ const Dashboard = () => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [eventActivity, setEventActivity] = useState(null);
|
||||
const [activityLoading, setActivityLoading] = useState(true);
|
||||
const joinedDate = user?.member_since || user?.created_at;
|
||||
|
||||
useEffect(() => {
|
||||
fetchUpcomingEvents();
|
||||
fetchEventActivity();
|
||||
}, []);
|
||||
|
||||
const fetchUpcomingEvents = async () => {
|
||||
@@ -32,6 +36,17 @@ const Dashboard = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEventActivity = async () => {
|
||||
try {
|
||||
const response = await api.get('/members/event-activity');
|
||||
setEventActivity(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch event activity:', error);
|
||||
} finally {
|
||||
setActivityLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setResendLoading(true);
|
||||
try {
|
||||
@@ -59,11 +74,14 @@ const Dashboard = () => {
|
||||
const getStatusBadge = (status) => {
|
||||
const statusConfig = {
|
||||
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_approval: { icon: Clock, label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_approved: { icon: CheckCircle, label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
|
||||
pending_validation: { icon: Clock, label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { icon: CheckCircle, label: 'Pre-Validated', className: 'bg-[var(--green-light)] text-white' },
|
||||
payment_pending: { icon: AlertCircle, label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
active: { icon: CheckCircle, label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
inactive: { icon: AlertCircle, label: 'Inactive', className: 'bg-gray-400 text-white' }
|
||||
active: { icon: CheckCircle, label: 'Active', className: 'bg-[var(--green-light)] text-white' },
|
||||
inactive: { icon: AlertCircle, label: 'Inactive', className: 'bg-gray-400 text-white' },
|
||||
canceled: { icon: AlertCircle, label: 'Canceled', className: 'bg-red-100 text-red-700' },
|
||||
expired: { icon: Clock, label: 'Expired', className: 'bg-red-500 text-white' },
|
||||
abandoned: { icon: AlertCircle, label: 'Abandoned', className: 'bg-gray-300 text-gray-600' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.inactive;
|
||||
@@ -80,41 +98,44 @@ const Dashboard = () => {
|
||||
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.',
|
||||
pending_validation: 'Your application is under review by our admin team.',
|
||||
pre_validated: '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.'
|
||||
inactive: 'Your membership is currently inactive.',
|
||||
canceled: 'Your membership has been canceled. Contact us to rejoin.',
|
||||
expired: 'Your membership has expired. Please renew to regain access.',
|
||||
abandoned: 'Your application was not completed. Contact us to restart the process.'
|
||||
};
|
||||
|
||||
return messages[status] || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<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 text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome Back, {user?.first_name}!
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Here's an overview of your membership status and upcoming events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email Verification Alert */}
|
||||
{user && !user.email_verified && (
|
||||
<Card className="p-6 bg-[#f1eef9] border-2 border-[#664fa3] mb-8">
|
||||
<Card className="p-6 bg-[var(--lavender-300)] border-2 border-brand-purple mb-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="h-6 w-6 text-[#664fa3] flex-shrink-0 mt-1" />
|
||||
<AlertCircle className="h-6 w-6 text-brand-purple flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Verify Your Email Address
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Please verify your email address to complete your registration.
|
||||
Check your inbox for the verification link.
|
||||
</p>
|
||||
@@ -122,7 +143,7 @@ const Dashboard = () => {
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendLoading}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
{resendLoading ? 'Sending...' : 'Resend Verification Email'}
|
||||
@@ -133,26 +154,26 @@ const Dashboard = () => {
|
||||
)}
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg mb-8" data-testid="status-card">
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] 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 text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Membership Status
|
||||
</h2>
|
||||
<div className="mb-4">
|
||||
{getStatusBadge(user?.status)}
|
||||
</div>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{getStatusMessage(user?.status)}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/profile">
|
||||
<Button
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
||||
className="btn-lavender"
|
||||
data-testid="view-profile-button"
|
||||
>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
View Profile
|
||||
Edit Profile
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -161,36 +182,36 @@ const Dashboard = () => {
|
||||
{/* Grid Layout */}
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="quick-stats-card">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Quick Info
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
|
||||
<p className="text-[#422268] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
{user?.subscription_start_date && user?.subscription_end_date && (
|
||||
<>
|
||||
<div className="pt-4 border-t border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="pt-4 border-t border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
|
||||
</p>
|
||||
</div>
|
||||
@@ -200,15 +221,14 @@ const Dashboard = () => {
|
||||
</Card>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<Card className="lg:col-span-2 p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="upcoming-events-card">
|
||||
<Card className="lg:col-span-2 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Upcoming Events
|
||||
</h3>
|
||||
<Link to="/events">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3]"
|
||||
className="btn-lavender "
|
||||
data-testid="view-all-events-button"
|
||||
>
|
||||
View All
|
||||
@@ -217,26 +237,26 @@ const Dashboard = () => {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-[#ddd8eb] rounded-xl hover:border-[#664fa3] hover:shadow-md transition-all cursor-pointer"
|
||||
className="p-4 border border-[var(--neutral-800)] rounded-xl hover:border-brand-purple hover:shadow-md transition-all cursor-pointer"
|
||||
data-testid={`event-card-${event.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,23 +265,23 @@ const Dashboard = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No upcoming events at the moment.</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Check back later for new events!</p>
|
||||
<Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No upcoming events at the moment.</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border border-[#ddd8eb]">
|
||||
{user?.status === 'pending_validation' && (
|
||||
<Card className="mt-8 p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Application Under Review
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your membership application is being reviewed by our admin team. You'll be notified once approved!
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your membership application is being reviewed by our admin team. You'll be notified once validated!
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -269,20 +289,20 @@ const Dashboard = () => {
|
||||
|
||||
{/* Payment Prompt for payment_pending status */}
|
||||
{user?.status === 'payment_pending' && (
|
||||
<Card className="mt-8 p-8 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border-2 border-[#664fa3]">
|
||||
<Card className="mt-8 p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border-2 border-brand-purple ">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<AlertCircle className="h-16 w-16 text-[#664fa3] mx-auto" />
|
||||
<AlertCircle className="h-16 w-16 text-brand-purple mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Complete Your Payment
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Great news! Your membership application has been approved. Complete your payment to activate your membership and gain full access to all member benefits.
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Great news! Your membership application has been validated. Complete your payment to activate your membership and gain full access to all member benefits.
|
||||
</p>
|
||||
<Link to="/plans">
|
||||
<Button
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-semibold"
|
||||
data-testid="complete-payment-cta"
|
||||
>
|
||||
<CheckCircle className="mr-2 h-5 w-5" />
|
||||
@@ -292,6 +312,156 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Event Activity Section */}
|
||||
<div className="mt-12">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
My Event Activity
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{activityLoading ? (
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event activity...</p>
|
||||
) : eventActivity ? (
|
||||
<div className="space-y-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-lg">
|
||||
<Calendar className="h-8 w-8 text-brand-purple " />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{eventActivity.total_rsvps}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-[var(--green-light)]/20 p-4 rounded-lg">
|
||||
<CheckCircle className="h-8 w-8 text-[var(--green-light)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Events Attended</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{eventActivity.total_attended}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upcoming RSVP'd Events */}
|
||||
{eventActivity.upcoming_events && eventActivity.upcoming_events.length > 0 && (
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Upcoming Events (RSVP'd)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{eventActivity.upcoming_events.map((event) => (
|
||||
<Link to={`/events/${event.id}`} key={event.id}>
|
||||
<div className="p-4 border border-[var(--neutral-800)] rounded-xl hover:border-brand-purple hover:shadow-md transition-all">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{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-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
|
||||
</div>
|
||||
<Badge className={
|
||||
event.rsvp_status === 'yes' ? 'bg-[var(--green-light)] text-white' :
|
||||
event.rsvp_status === 'maybe' ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-gray-200 text-gray-700'
|
||||
}>
|
||||
{event.rsvp_status === 'yes' ? 'Going' :
|
||||
event.rsvp_status === 'maybe' ? 'Maybe' : 'Not Going'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Past Events & Attendance */}
|
||||
{eventActivity.past_events && eventActivity.past_events.length > 0 && (
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Past Events
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{eventActivity.past_events.slice(0, 5).map((event) => (
|
||||
<div key={event.id} className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(event.start_at).toLocaleDateString()} at{' '}
|
||||
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge className={event.attended ? 'bg-[var(--green-light)] text-white' : 'bg-gray-200 text-gray-700'}>
|
||||
{event.attended ? 'Attended' : 'Did not attend'}
|
||||
</Badge>
|
||||
{event.attended && event.attended_at && (
|
||||
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Checked in: {new Date(event.attended_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{eventActivity.past_events.length > 5 && (
|
||||
<p className="text-sm text-center text-brand-purple mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Showing 5 of {eventActivity.past_events.length} past events
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No Events Message */}
|
||||
{(!eventActivity.upcoming_events || eventActivity.upcoming_events.length === 0) &&
|
||||
(!eventActivity.past_events || eventActivity.past_events.length === 0) && (
|
||||
<Card className="p-12 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="text-center">
|
||||
<Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Event Activity Yet
|
||||
</h3>
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Browse upcoming events and RSVP to start building your event history!
|
||||
</p>
|
||||
<Link to="/events">
|
||||
<Button className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender px-6">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Browse Events
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-12 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Failed to load event activity. Please try refreshing the page.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
|
||||
@@ -55,33 +55,37 @@ const Donate = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-white to-[#f1eef9] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
<main className="bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
{/* Hero Section */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="max-w-4xl mx-auto text-center h-full">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img src={loafHearts} alt="Hearts" className="w-32 h-auto" onError={(e) => e.target.style.display = 'none'} />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[var(--purple-deep)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Donate
|
||||
</h1>
|
||||
<p className="text-xl text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xl text-[var(--purple-deep)] font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
We really appreciate your donations. You can make your donation online
|
||||
or send a check by mail.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Columns */}
|
||||
<div className="py-12">
|
||||
<div className='grid grid-cols-1 items-stretch lg:grid-cols-[2fr_1fr] gap-8 lg:max-h-[450px]'>
|
||||
|
||||
{/* Donation Amount Buttons */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<section className="flex flex-col h-full">
|
||||
<div className="mx-auto flex-1 w-full h-full">
|
||||
<Card className="p-8 bg-background rounded-3xl w-full h-full content-center">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<CreditCard className="w-12 h-12 text-[#664fa3]" />
|
||||
<h2 className="text-3xl font-bold text-[#48286e]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CreditCard className="size-24 text-[var(--purple-lavender)]" />
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Select Your Donation Amount
|
||||
</h2>
|
||||
</div>
|
||||
@@ -93,7 +97,7 @@ const Donate = () => {
|
||||
key={amount}
|
||||
onClick={() => handleDonateAmount(amount * 100)}
|
||||
disabled={processingAmount === amount * 100}
|
||||
className="bg-[#664fa3] hover:bg-[#48286e] text-white text-xl py-8 rounded-full disabled:opacity-50"
|
||||
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white text-xl py-8 rounded-full disabled:opacity-50"
|
||||
>
|
||||
{processingAmount === amount * 100 ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
@@ -108,13 +112,13 @@ const Donate = () => {
|
||||
<Button
|
||||
onClick={() => setCustomAmountDialogOpen(true)}
|
||||
disabled={processingAmount !== null}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#48286e] text-white text-xl py-8 rounded-full flex items-center justify-center gap-2"
|
||||
className="w-full bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white text-xl py-8 rounded-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<Heart className="h-6 w-6" />
|
||||
Donate Any Amount
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-[#664fa3] text-center mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-[var(--purple-lavender)] text-center mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Secure donation processing powered by Stripe
|
||||
</p>
|
||||
</Card>
|
||||
@@ -122,66 +126,76 @@ const Donate = () => {
|
||||
</section>
|
||||
|
||||
{/* Alternative Payment Methods */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<section className="flex flex-col">
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
{/* Mail Check */}
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<Mail className="w-12 h-12 text-[#664fa3] mb-4" />
|
||||
<h3 className="text-2xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-background rounded-3xl flex gap-4 items-center flex-1">
|
||||
<Mail className="size-24 text-[var(--purple-lavender)]" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Mail a Check
|
||||
</h3>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our mailing address for checks:<br />
|
||||
<span className="font-semibold">LOAF</span><br />
|
||||
P.O. Box 7207<br />
|
||||
Houston, Texas 77248-7207
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Zelle */}
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<div className="mb-4">
|
||||
<img src={zelleLogo} alt="Zelle" className="h-32" onError={(e) => e.target.style.display = 'none'} />
|
||||
<Card className="p-8 bg-background rounded-3xl flex gap-4 items-center flex-1">
|
||||
<div className="w-44">
|
||||
<img src={zelleLogo} alt="Zelle" className=" w-32" onError={(e) => e.target.style.display = 'none'} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Pay with Zelle
|
||||
</h3>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-[var(--purple-deep)] leading-relaxed mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If your bank allows the use of Zelle, please feel free to send money to:
|
||||
</p>
|
||||
<a href="mailto:LOAFHoustonTX@gmail.com"
|
||||
className="text-[#664fa3] text-lg font-bold underline hover:text-[#48286e] transition-colors"
|
||||
className="text-[var(--purple-lavender)] text-lg font-bold underline hover:text-[var(--purple-deep)] transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAFHoustonTX@gmail.com
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Columns end */}
|
||||
</main>
|
||||
|
||||
<PublicFooter />
|
||||
|
||||
{/* Custom Amount Dialog */}
|
||||
<Dialog open={customAmountDialogOpen} onOpenChange={setCustomAmountDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[450px] bg-white rounded-2xl">
|
||||
<DialogContent className="sm:max-w-[450px] bg-background rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Enter Donation Amount
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Choose how much you'd like to donate to support our community
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="customAmount" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="customAmount" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Amount (USD)
|
||||
</Label>
|
||||
<div className="relative mt-2">
|
||||
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-[#664fa3] text-xl font-semibold">
|
||||
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-[var(--purple-lavender)] text-xl font-semibold">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
@@ -192,7 +206,7 @@ const Donate = () => {
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
placeholder="50.00"
|
||||
className="pl-10 h-14 text-xl border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-xl"
|
||||
className="pl-10 h-14 text-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-xl"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCustomDonate();
|
||||
@@ -200,13 +214,13 @@ const Donate = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Minimum donation: $1.00
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f1eef9] rounded-lg p-4">
|
||||
<p className="text-sm text-[#422268] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="bg-[var(--lavender-300)] rounded-lg p-4">
|
||||
<p className="text-sm text-[var(--purple-ink)] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Thank you for supporting LOAF!</strong><br />
|
||||
Your donation helps us continue our mission and provide meaningful experiences for our community.
|
||||
</p>
|
||||
@@ -218,14 +232,14 @@ const Donate = () => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCustomAmountDialogOpen(false)}
|
||||
className="rounded-full border-2 border-[#ddd8eb]"
|
||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCustomDonate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#48286e] rounded-full"
|
||||
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-deep)] rounded-full"
|
||||
>
|
||||
Continue to Payment
|
||||
</Button>
|
||||
|
||||
@@ -11,12 +11,12 @@ const DonationSuccess = () => {
|
||||
const loafHearts = `${process.env.PUBLIC_URL}/loaf-hearts.png`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-white to-[#f1eef9] px-6 py-20">
|
||||
<main className="bg-gradient-to-b from-white to-[var(--lavender-300)] px-6 py-20">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card className="p-6 sm:p-8 md:p-12 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-xl text-center">
|
||||
<Card className="p-6 sm:p-8 md:p-12 bg-background rounded-2xl border-2 border-[var(--neutral-800)] shadow-xl text-center">
|
||||
{/* Success Icon */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
@@ -26,34 +26,34 @@ const DonationSuccess = () => {
|
||||
onError={(e) => e.target.style.display = 'none'}
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-[#81B29A]/10 rounded-full mb-6">
|
||||
<CheckCircle className="h-12 w-12 text-[#81B29A]" />
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-[var(--green-light)]/10 rounded-full mb-6">
|
||||
<CheckCircle className="h-12 w-12 text-[var(--green-light)]" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Thank You for Your Donation!
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<p className="text-xl text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xl text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your generous contribution helps support our community and continue our mission.
|
||||
</p>
|
||||
|
||||
<div className="bg-gradient-to-r from-[#f1eef9] to-[#DDD8EB]/30 rounded-2xl p-6 border-2 border-[#ddd8eb]">
|
||||
<div className="flex items-center justify-center gap-2 text-[#ff9e77] mb-2">
|
||||
<div className="bg-gradient-to-r from-[var(--lavender-300)] to-[var(--neutral-800)]/30 rounded-2xl p-6 border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-center gap-2 text-[var(--orange-light)] mb-2">
|
||||
<Heart className="h-6 w-6" />
|
||||
<span className="text-lg font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Your Support Makes a Difference
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
A receipt for your donation has been sent to your email address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-base text-[#664fa3] pt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-base text-brand-purple pt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
We deeply appreciate your support and commitment to LOAF's mission of building a vibrant, inclusive community.
|
||||
</p>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@ const DonationSuccess = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
onClick={() => navigate('/')}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full px-8 py-6 text-lg font-medium shadow-lg"
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-8 py-6 text-lg font-medium shadow-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Return to Home
|
||||
@@ -70,7 +70,7 @@ const DonationSuccess = () => {
|
||||
<Button
|
||||
onClick={() => navigate('/donate')}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#DDD8EB]/20 rounded-full px-8 py-6 text-lg font-medium"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--neutral-800)]/20 rounded-full px-8 py-6 text-lg font-medium"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Make Another Donation
|
||||
@@ -80,12 +80,12 @@ const DonationSuccess = () => {
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-sm text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Have questions about your donation?
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@loaf.org"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3] font-medium transition-colors"
|
||||
className="text-[var(--orange-light)] hover:text-brand-purple font-medium transition-colors"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Contact us at support@loaf.org
|
||||
|
||||
@@ -48,10 +48,10 @@ const EventDetails = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -62,30 +62,29 @@ const EventDetails = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
<button
|
||||
onClick={() => navigate('/events')}
|
||||
className="inline-flex items-center text-[#664fa3] hover:text-[#ff9e77] transition-colors mb-8"
|
||||
className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] 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-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="bg-[#DDD8EB]/20 p-4 rounded-xl">
|
||||
<Calendar className="h-10 w-10 text-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-xl">
|
||||
<Calendar className="h-10 w-10 text-brand-purple " />
|
||||
</div>
|
||||
{event.user_rsvp_status && (
|
||||
<Badge
|
||||
className={`px-4 py-2 rounded-full text-sm ${
|
||||
event.user_rsvp_status === 'yes'
|
||||
? 'bg-[#81B29A] text-white'
|
||||
className={`px-4 py-2 rounded-full text-sm ${event.user_rsvp_status === 'yes'
|
||||
? 'bg-[var(--green-light)] text-white'
|
||||
: event.user_rsvp_status === 'no'
|
||||
? 'bg-gray-400 text-white'
|
||||
: 'bg-orange-100 text-orange-700'
|
||||
@@ -98,12 +97,12 @@ const EventDetails = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{event.title}
|
||||
</h1>
|
||||
|
||||
<div className="space-y-4 text-lg">
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-brand-purple ">
|
||||
<Calendar className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(event.start_at).toLocaleDateString('en-US', {
|
||||
@@ -114,18 +113,18 @@ const EventDetails = () => {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-brand-purple ">
|
||||
<Calendar className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{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-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-brand-purple ">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-brand-purple ">
|
||||
<Users className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{event.rsvp_count || 0} {event.rsvp_count === 1 ? 'person' : 'people'} attending
|
||||
@@ -136,28 +135,27 @@ const EventDetails = () => {
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<div className="mb-8 pb-8 border-b border-[#ddd8eb]">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="mb-8 pb-8 border-b border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
About This Event
|
||||
</h2>
|
||||
<p className="text-[#664fa3] leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
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-[#DDD8EB] text-[#422268] hover:bg-white'
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 ${event.user_rsvp_status === 'yes'
|
||||
? 'bg-[var(--green-light)] text-white'
|
||||
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-lavender'
|
||||
}`}
|
||||
data-testid="rsvp-yes-button"
|
||||
>
|
||||
@@ -168,10 +166,9 @@ const EventDetails = () => {
|
||||
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'
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${event.user_rsvp_status === 'maybe'
|
||||
? 'border-orange-400 bg-orange-100 text-orange-700'
|
||||
: 'border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9]'
|
||||
: 'border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)]'
|
||||
}`}
|
||||
data-testid="rsvp-maybe-button"
|
||||
>
|
||||
@@ -182,8 +179,7 @@ const EventDetails = () => {
|
||||
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'
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${event.user_rsvp_status === 'no'
|
||||
? 'border-gray-400 bg-gray-100 text-gray-700'
|
||||
: 'border-gray-400 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
@@ -195,11 +191,11 @@ const EventDetails = () => {
|
||||
</div>
|
||||
|
||||
{/* Add to Calendar Section */}
|
||||
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
|
||||
<h2 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="mt-8 pt-8 border-t border-[var(--neutral-800)]">
|
||||
<h2 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Add to Your Calendar
|
||||
</h2>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Never miss this event! Add it to your calendar app for reminders.
|
||||
</p>
|
||||
<AddToCalendarButton
|
||||
|
||||
@@ -30,7 +30,7 @@ const Events = () => {
|
||||
if (!rsvpStatus) return null;
|
||||
|
||||
const config = {
|
||||
yes: { label: 'Going', className: 'bg-[#81B29A] text-white' },
|
||||
yes: { label: 'Going', className: 'bg-[var(--green-light)] text-white' },
|
||||
no: { label: 'Not Going', className: 'bg-gray-400 text-white' },
|
||||
maybe: { label: 'Maybe', className: 'bg-orange-100 text-orange-700' }
|
||||
};
|
||||
@@ -46,67 +46,67 @@ const Events = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<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 text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Upcoming Events
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Browse and RSVP to our community events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
</div>
|
||||
) : events.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
|
||||
{events.map((event) => (
|
||||
<Link to={`/events/${event.id}`} key={event.id}>
|
||||
<Card
|
||||
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer h-full"
|
||||
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] 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-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
{getRSVPBadge(event.user_rsvp_status)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
{event.description && (
|
||||
<p className="text-[#664fa3] mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-[#664fa3]">
|
||||
<div className="flex items-center gap-2 text-brand-purple ">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{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-[#664fa3]">
|
||||
<div className="flex items-center gap-2 text-brand-purple ">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[#664fa3]">
|
||||
<div className="flex items-center gap-2 text-brand-purple ">
|
||||
<Users className="h-4 w-4" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.rsvp_count || 0} attending</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center text-[#ff9e77] font-medium">
|
||||
<div className="mt-6 flex items-center text-[var(--orange-light)] font-medium">
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
@@ -116,11 +116,11 @@ const Events = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<Calendar className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Calendar className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Events Available
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
There are no upcoming events at the moment. Check back later!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -32,28 +32,28 @@ const ForgotPassword = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
<Link to="/login" className="inline-flex items-center text-[#664fa3] hover:text-[#ff9e77] transition-colors">
|
||||
<Link to="/login" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
{!submitted ? (
|
||||
<>
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[#f1eef9] mb-4">
|
||||
<Mail className="h-8 w-8 text-[#664fa3]" />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--lavender-300)] mb-4">
|
||||
<Mail className="h-8 w-8 text-brand-purple " />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Forgot Password?
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No worries! Enter your email and we'll send you reset instructions.
|
||||
</p>
|
||||
</div>
|
||||
@@ -69,22 +69,22 @@ const ForgotPassword = () => {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your.email@example.com"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Remember your password?{' '}
|
||||
<Link to="/login" className="text-[#ff9e77] hover:underline font-medium">
|
||||
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
|
||||
Login here
|
||||
</Link>
|
||||
</p>
|
||||
@@ -92,21 +92,21 @@ const ForgotPassword = () => {
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[#E8F5E9] mb-6">
|
||||
<CheckCircle className="h-8 w-8 text-[#4CAF50]" />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--green-bg)] mb-6">
|
||||
<CheckCircle className="h-8 w-8 text-[var(--green-success)]" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Check Your Email
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If an account exists for <span className="font-medium text-[#422268]">{email}</span>,
|
||||
<p className="text-lg text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If an account exists for <span className="font-medium text-[var(--purple-ink)]">{email}</span>,
|
||||
you will receive a password reset link shortly.
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The link will expire in 1 hour. If you don't see the email, check your spam folder.
|
||||
</p>
|
||||
<Link to="/login">
|
||||
<Button className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform">
|
||||
<Button className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform">
|
||||
Return to Login
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,35 @@ import PublicFooter from '../components/PublicFooter';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Pen } from 'lucide-react';
|
||||
import { LuArrowDown } from "react-icons/lu";
|
||||
|
||||
const CardSection = ({ children, className = '', arrow = true }) => (
|
||||
<section className={` ${className}`}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Card className="p-14 bg-background rounded-3xl">
|
||||
{children}
|
||||
</Card>
|
||||
</div>
|
||||
{arrow && (<div className="flex text-2xl my-5 justify-center">
|
||||
<LuArrowDown />
|
||||
</div>)}
|
||||
{!arrow && (
|
||||
<div className="mb-12"></div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
const Title = ({ children }) => (
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
const Paragragh = ({ children }) => (
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
const History = () => {
|
||||
const ardenCharlotteImg = `${process.env.PUBLIC_URL}/history-arden-charlotte.png`;
|
||||
@@ -12,20 +41,21 @@ const History = () => {
|
||||
const part3Img = `${process.env.PUBLIC_URL}/history-part3.png`;
|
||||
const part7Img = `${process.env.PUBLIC_URL}/history-part7.png`;
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-white to-[#f1eef9] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
<main className="bg-gradient-to-br from-[var(--neutral-100:)] to-[var(--neutral-700)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
{/* Hero Section */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto text-center">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-4"
|
||||
<div className="max-w-3xl mx-auto flex justify-around mb-12 flex-col gap-6 items-center lg:flex-row">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-[var(--purple-deep)] "
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
History of LOAF
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-2 text-[#48286e]">
|
||||
<Pen className="h-5 w-5" />
|
||||
<div className="flex items-center justify-center gap-6 text-[var(--purple-deep)]">
|
||||
<Pen className="size-7" />
|
||||
<p className="text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
By Arden Eversmeyer
|
||||
</p>
|
||||
@@ -34,223 +64,211 @@ const History = () => {
|
||||
</section>
|
||||
|
||||
{/* Part 1 - With Image */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<CardSection>
|
||||
<div className="flex flex-col md:flex-row gap-14 items-center">
|
||||
<div className="md:w-1/3 ">
|
||||
<img src={ardenCharlotteImg} alt="Arden Eversmeyer and Charlotte Avery"
|
||||
className="rounded-lg w-full" onError={(e) => e.target.style.display = 'none'} />
|
||||
<p className="text-sm text-[#48286e] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-[var(--purple-deep)] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Arden Eversmeyer and Charlotte Avery
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Part 1
|
||||
</h2>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In 1985 my life partner of 33 years died. For many years we had been part of a large "friendship group" that got together for meals and games. After her death, I found myself on the edge of the group. I felt invisible. The group, composed primarily of couples, didn't know what to do with the single person they had suddenly become.
|
||||
<Title>Part 1</Title>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In 1985 my life partner of 33 years died. For many years we had been part of a large “friendship group”, many of whom had been together longer than we had. But I was the first to lose a partner. After a few months I began to feel the need to explore community. Already retired, the necessity of being closeted was gone. I soon discovered there was no group for mid-life an old lesbians in Houston, and began the search for such groups around the U.S.
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
When I moved to Houston in 1992, I again found myself isolated. I had friends, but not being "coupled" in a "couples world" left me on the outside. I was aware of my advancing age – I was 63 at the time - and I was sure that I was the only "old lesbian" in Houston. I checked out the Montrose bars, but to my dismay, found that older lesbians were non-existent; at least they didn't hang out in bars.
|
||||
<p className="text-md mb-4 text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In April of 1987 I loaded my camper and headed for California. I started in San Diego, and worked my way up the coast to San Francisco finding and visiting senior LGBT groups. I came home with much information, and some suggestions about organizing. One fact that was consistent with all the groups was that if the group was for both men and women - the women dropped out. The recommendation was to start a group for women only.
|
||||
</p>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>I had become friends with some young lesbians here in Houston who knew the Gay community. We started meeting and brainstorming, and the group now known as LOAF was born.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</CardSection>
|
||||
{/* Arrow */}
|
||||
|
||||
{/* Part 2 */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Part 2
|
||||
</h2>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CardSection >
|
||||
<Title>Part 2</Title>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The Founding Mothers of LOAF are Ruth Sathre (nurse), JoAnn Beene (psychologist), Delores Nason (business woman), JoAnn Loulan (psychologist and writer, now living in Guerneville, CA), and Judy Peyton (social worker). We decided to form a group for Lesbians Over Fifty and began the search for others "like us."
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In 1993, we advertised in OutSmart Magazine, the Houston gay magazine, and invited interested women to join us at a local restaurant. Founding mothers and 19 other women came to that first meeting. Since then the group has gone through many evolutions.
|
||||
</p>
|
||||
<ul className="list-disc ml-6 mt-4 space-y-2 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li><strong>AGE OF PARTICIPANTS</strong> - we started off as LOAFF - Lesbians over Age Fifty-Five. The extra F stood for 55, which didn't work very well, so we changed to LOAF and lowered the age to 50.</li>
|
||||
<li><strong>NAME FOR THE GROUP</strong> - LOAFF and then LOAF</li>
|
||||
<li><strong>NUMBER OF EVENTS</strong> - Some of the early years we had events every Saturday afternoon, but as we aged, we cut back to one event each month, then we went to the current format of one event during the week, either afternoon or evening, and a weekend activity.</li>
|
||||
<li><strong>TYPES OF EVENTS</strong> - We've had LOTS of different events. Some of the events we have had include: going to a museum, going to the symphony, seeing a play or movie together, going out to dinner, pot luck dinners, game nights, campfires, hiking, kayaking, and more.</li>
|
||||
<ul className="list-disc ml-6 mt-4 space-y-2 text-md text-[var(--purple-deep)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li>AGE OF PARTICIPANTS - We launched as LOAFF (Lesbians Over Age Fifty-Five) and quickly lowered the entry age to fifty so more women could join.</li>
|
||||
<li>NAME FOR THE GROUP - The acronym began as LOAFF, then we streamlined it to the now-familiar LOAF.</li>
|
||||
<li>AMOUNT OF STRUCTURING - Too many rules can smother a grassroots group, so we kept things loose and let participating women guide direction as needs evolved.</li>
|
||||
<li>SAFE HAVEN FOR MEETINGS - Gatherings had to be in discreet, transit-accessible locations, scheduled during daylight (often Sundays) so closeted or non-driving members could attend comfortably.</li>
|
||||
<li>NEWSLETTER - A monthly mailing went out before each month's end, highlighting news plus upcoming activities tailored to the community.</li>
|
||||
<li>DUES - Contributions were set at $2 per month per woman, with a standing policy that anyone unable to pay was still welcome—unchanged since inception.</li>
|
||||
<li>CREATIVE PUBLICITY - We produced flyers and placed them with LGBTQ+ organizations, counselors, and other allies, recognizing the women we hoped to reach wouldn't necessarily be found in bars and would arrive mostly via word of mouth.</li>
|
||||
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</CardSection>
|
||||
|
||||
{/* Part 3 - With Image */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<div className="md:w-2/3">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Part 3
|
||||
</h2>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
We have never had a formal organization with by-laws and officers. We have operated on a consensus basis with the founders making most of the decisions. One of the early decisions we made was that we would not have any kind of formal membership. We wanted to be as inclusive as possible and not create any barriers to participation.
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
We have always been self-supporting. We have never charged dues or asked for donations. Each person pays for their own meal or activity. We have never had a budget or a bank account. We have been able to operate this way because we have always kept our activities simple and inexpensive.
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:w-1/3">
|
||||
<CardSection >
|
||||
<div className="flex gap-14 flex-col min-w-2xl md:flex-row justify-center items-center">
|
||||
<div className="">
|
||||
<img src={part3Img} alt="LOAF Community"
|
||||
className="rounded-lg w-full" onError={(e) => e.target.style.display = 'none'} />
|
||||
<p className="text-sm text-[#48286e] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF Community
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Part 4 - With Image */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<div className="md:w-2/3">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Part 4
|
||||
</h2>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Over the years, LOAF has been a place where women can be themselves, where they can talk about their lives and their experiences without fear of judgment. We have created a safe space for women to explore their sexuality and their identity as lesbians.
|
||||
</div>
|
||||
<div className="">
|
||||
<Title>Part 3</Title>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The first meeting of LOAF was the third Sunday of October 1987 at Womynspace. There were six women besides myself at that first meeting. attending were Betty Rudnick, Billie Carter, Josephine Jones, Sylvia Porter, Marjorie Fulp, and Charlotte Avery. Of those six women, only Sylvia Porter and Charlotte Avery are still alive.
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Many women have told us that LOAF has been a lifeline for them, especially as they age and find themselves increasingly isolated. LOAF has provided a community and a sense of belonging that has been invaluable.
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Much that we discussed that day is still the heart of the group today - decisions by consensus at monthly meetings and activities governed by needs and wishes of participating women. It was soon decided to make the age requirement for membership age 50, and we became LOAF.
|
||||
</p>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In 1989 member Jo Stewart, social worker at Methodist Hospital, started urging LOAF to incorporate as a non-profit. The work began in 1990 with Moore & Hunt (Debbie Hunt) as our Corporate Attorneys. Jo died of cancer in 1990. The work for application of our 501(c)3 was done by Floi Ewing, Arden's sister, and our non-profit status was granted in January 1991. Loaf incorporated as a social networking and support group without a membership roll to protect the anonymity of the women in LOAF.
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:w-1/3">
|
||||
</div>
|
||||
|
||||
</CardSection>
|
||||
|
||||
{/* Part 4 */}
|
||||
<CardSection>
|
||||
<div className=" ">
|
||||
<Title>Part 4</Title>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Third Sunday meeting places have changed over the years. We moved from Womynspace to Autrey House near Rice University. We were there from November 1987 until May 1990 when the new Bishop dis-invited all GLBT groups because of homophobia. We spent a couple months at Montrose Counseling Center (on Lovett), and then moved to the Metropolitan Multi- Service Center on W. Gray. We met there from August 1990 until January 1993. We left because the city started closing the centers on Sunday, and we were not willing to change our meeting day. From February through June we met at Inklings Book Store . In July we started our long occupancy with Houston Mission Church, and met there until the church dissolved in April 2001. We then met at the Hollyfield Center for seven months. From there we went to the GLBT Community Center on Hawthorne where we stayed until July 2003. Attendance was dropping off, and some of the women were not comfortable in a gay identified place. So Third Sunday Meetings moved to Charlotte and Arden's home - and we met there from August 2003 until April 2011. Membership had grown until the meetings had reached critical mass and parking was a problem. So a team of board members started researching for a new home. And on the third Sunday of May 2011 LOAF started meeting at the Montrose Counseling Center. A new era had started.
|
||||
</p>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
{/* Part 5 - With Image */}
|
||||
<CardSection >
|
||||
<div className="flex gap-8 flex-col lg:flex-row justify-center items-center md:items-start">
|
||||
<div className="flex flex-col gap-8 w-full lg:w-1/2">
|
||||
<img src={pride1Img} alt="Pride Parade"
|
||||
className="rounded-lg w-full" onError={(e) => e.target.style.display = 'none'} />
|
||||
<img src={pride2Img} alt="Pride Parade"
|
||||
className="rounded-lg w-full" onError={(e) => e.target.style.display = 'none'} />
|
||||
<p className="text-sm text-[#48286e] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF at Pride
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Part 5 */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Part 5
|
||||
</h2>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF has also been a place where women can give back to the community. Many of our members have been active in various LGBTQ+ organizations and causes. We have marched in Pride parades, volunteered at LGBTQ+ events, and supported various LGBTQ+ initiatives.
|
||||
<div className="w-full lg:w-1/2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Title>Part 5</Title>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" >
|
||||
The activities of the monthly meetings were decided by the participating women. Rules were very limited, and decisions were made monthly by the women attending. We soon decided to set up a quarterly meeting schedule of pot-luck, speaker, and games. We followed this schedule for at least ten years. New ideas/programs were added as time allowed. Our first speaker was Pokey Anderson, an icon in the GLBT community. She provided much information about our history in Houston. We also decided to ask Deb Hunt to talk to us about documents, and she has done this periodically over the years. She spoke to us well before we decided to incorporate, and she then became our Corporate Attorney.
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
As we look to the future, we are committed to continuing to provide a welcoming and inclusive space for lesbians over 50. We know that there are many women out there who are looking for a community like ours, and we want to make sure that they know that LOAF is here for them.
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In November 1988 we instituted our annual Benevolent Project. We collected items to contribute to Stone Soup Kitchen - a GLBT food pantry. We have done this every year since, and have contributed to groups such as Omega House, The Rose, Battered Women, local lesbian organizations, and some individuals.
|
||||
</p>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In June 1989 we entered our first Pride Parade. That year we had a convertible and a walking group. Several of the LOAF women participate with other groups in the parade, but we have participated every year since 1989. In 2010 we entered our first float and won a trophy for best representation of the theme.
|
||||
</p>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
All of these decisions were made by the women at the Third Sunday meetings. There have never been rules instigated by the Board of Directors. Because many women don't want to attend meetings, we changed Third Sunday Meeting to Meet 'N Greet several years ago. And that is what we do - take care of any necessary business. But greet newcomers and socialize with our friends.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</CardSection>
|
||||
|
||||
|
||||
{/* Part 6 */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CardSection >
|
||||
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Part 6
|
||||
</h2>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
One of the things that has made LOAF special is the diversity of our members. We have women from all walks of life, all backgrounds, all races, all religions, and all political persuasions. What we have in common is our age and our sexual orientation.
|
||||
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Socials have always been a big part of the activities for LOAF, and having a social as well as the monthly “Third Sunday” meeting was always on the calendar. Pot lucks topped the list for many years, and they were hosted by women in their homes. That is more difficult now because of the size of the membership. Bev and Sandy have hosted a potluck since 1995, and Eva Geer for several years.
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
We have learned so much from each other over the years. We have shared our stories, our wisdom, and our experiences. We have laughed together, cried together, and supported each other through good times and bad.
|
||||
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The ice cream socials started in 1989. There are still women who have never cranked or eaten home made ice cream.The “picnic in the park” started in 2000. We have held picnics in a couple State Parks as well as Tom Bass Park in recent years.In 1988 we started attending the TUTS Broadway Musical at Miller Theater in July. We bring a snack supper and a chair and sit on the hill.In 2000 we started eating at Sudie's Catfish House in January. A breather from a busy party season, but a good way to connect.From 1987 to 1994 we had “Second Tuesday Dancing”. First at The Ranch, and then at Ms B's, it was our way to celebrate birthdays of the month. It was well attended.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
From 1989 to 1993 we had a five day Thanksgiving camp-out at a State Park. We roasted turkey and women came for potluck on Thanksgiving Day.We had from 10 to 20 campers, and maybe 25 for dinner.We have had a Christmas party every year since 1987.We have had several Port of Houston tours on the Sam Houston; several Houston Zoo tours; museum tours; and out-of town tours for bluebonnets, miniature horses, and Blue Bell ice cream. And occasionally, for lack of inspiration for an event social, we simply met for lunch at a local restaurant.
|
||||
</p>
|
||||
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF has always been a social group. We have never had support groups or counseling. We are a place to meet other lesbians over fifty, make friends, and have fun.
|
||||
</p>
|
||||
|
||||
</CardSection>
|
||||
|
||||
{/* Part 7 - With Image */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<CardSection>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 items-center">
|
||||
<div className="md:w-1/3">
|
||||
<img src={part7Img} alt="LOAF Members"
|
||||
className="rounded-lg w-full" onError={(e) => e.target.style.display = 'none'} />
|
||||
<p className="text-sm text-[#48286e] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF Members
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Part 7
|
||||
</h2>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF has evolved over the years, but our core mission has remained the same: to provide a welcoming and inclusive community for lesbians over 50. We have adapted to the changing times and the changing needs of our members, but we have never lost sight of what makes LOAF special.
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The LOAF Library has been an important part of the offering to the women. It started about 1987 when Arden discovered there were books - both fiction and non-fiction - about lesbians. We had one bookstore then -” Wilde 'N Stein” - that had a limited selection of lesbian books. Then Arden discovered Womencraft Books, a mail order book company. This began the collection now in the library. Over the years women have donated books. At one time we took duplicate titles to our book stores (Inkilngs and Book Woman) and traded them for titles we didn't have on the shelf. When the last book store closed we started donating duplicate copies to HATCH, and they are building their library. We have a collection that includes feminist, fantasy/sci-fi, poetry, non-fiction, as well as fiction books. We have a collection of out-of-print periodicals, women's music, and a video library. We have some beautiful “coffee table” books. We have copies of many of the “pulp” books.
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
We are proud of what we have accomplished over the years, and we are excited about the future. We know that there will always be a need for a community like LOAF, and we are committed to being here for as long as we are needed.
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF also was gifted with a beautiful pianola player piano and about 150 piano rolls. Marie Mariano donated it several years ago. It was in the “Allison” flood, and when it was restored Arden was told it was a beautiful instrument, and quite valuable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</CardSection>
|
||||
|
||||
{/* Part 8 */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Card className="p-8 bg-white rounded-2xl shadow-lg">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CardSection arrow={false} >
|
||||
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Part 8
|
||||
</h2>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
As I reflect on the history of LOAF, I am filled with gratitude for all of the women who have been part of this community over the years. Each one of you has made LOAF what it is today, and I am so proud of what we have created together.
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF has become a unique organization in that it is the oldest lesbian organization in Houston, and the only one of its kind in Texas. Over the years there has been quite a bit of exposure and promotion for LOAF.
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF has been a place where we can be ourselves, where we can celebrate who we are, and where we can support each other through all of life's challenges. It has been a place of joy, laughter, friendship, and love.
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
For 17 consecutive years, from 1987 to 2004, we had a Texas Lesbian Conference that rotated between Houston, San Antonio, Austin, and Dallas. LOAF presented workshops at five of these conferences. LOAF did a workshop at the National Lesbian Conference in Atlanta in 1991. We did a workshop at at the PFLAG “Healing the Hurt” conference in 1994. We did a program at the Silver Threads conference in St Petersburg, FL. We have done programs at three OLOC conferences. Charlotte and Arden participated in a live TV show about senior GLBT persons in Dallas. We participated in a documentary on GLBT seniors produced in Canada. And another documentary for Golden Threads at Cape Cod. We participated on a panel for the Women’s Studies Department at the University of Houston for their “Living archive” series. We have done several programs for the Women’s Group in Houston, and appeared on the After Hours radio show on KPFT several times.
|
||||
</p>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Thank you for being part of LOAF. Thank you for making this community what it is. And thank you for continuing to support LOAF into the future.
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
All of these appearances are documented and part of the LOAF Archives located at the University of Houston Special Collections Library. One result of these workshops and programs has been the formation of similar groups for mid-life and old lesbians throughout the country. But most important is the connection with other lesbians of our generation and avoiding isolation..
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</CardSection>
|
||||
|
||||
</main>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-12 bg-[#48286e] rounded-2xl">
|
||||
<div className="max-w-5xl mx-auto px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Card className="p-8 text-center bg-white rounded-2xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<section className="py-20 bg-[var(--purple-deep)] mx-0">
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<div className="flex gap-8 md:flex-row flex-col">
|
||||
<Card className="p-8 text-center bg-background rounded-2xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
A Life Remembered
|
||||
</h3>
|
||||
<p className="text-[#48286e] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Check out "A Life Remembered", a tribute dedicated to Arden Eversmeyer, one of the founding mothers of LOAF.
|
||||
</p>
|
||||
<a href="https://www.oldlesbianhistory.org/arden-eversmeyer" target="_blank" rel="noopener noreferrer">
|
||||
<Button className="bg-[#664fa3] hover:bg-[#48286e] text-white rounded-full px-6 py-3">
|
||||
<Button className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white rounded-full px-6 py-3">
|
||||
View Arden's Tribute
|
||||
</Button>
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
<Card className="p-8 text-center bg-white rounded-2xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 text-center bg-background rounded-2xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
The Old Lesbian Oral Herstory Project
|
||||
</h3>
|
||||
<p className="text-[#48286e] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Arden Eversmeyer was also involved with The Old Lesbian Oral Herstory Project, preserving the stories of old lesbians.
|
||||
</p>
|
||||
<a href="https://www.olohp.org" target="_blank" rel="noopener noreferrer">
|
||||
<Button className="bg-[#664fa3] hover:bg-[#48286e] text-white rounded-full px-6 py-3">
|
||||
<Button className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white rounded-full px-6 py-3">
|
||||
Learn More About OLOHP
|
||||
</Button>
|
||||
</a>
|
||||
@@ -258,7 +276,6 @@ const History = () => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
|
||||
@@ -7,26 +7,89 @@ import PublicFooter from '../components/PublicFooter';
|
||||
|
||||
const Landing = () => {
|
||||
// LOAF brand assets (local)
|
||||
const taglineImage = `${process.env.PUBLIC_URL}/tagline-image.png`;
|
||||
const shootingStar = `${process.env.PUBLIC_URL}/shooting-star.png`;
|
||||
const taglineImage = `${process.env.PUBLIC_URL}/web_elements_tagline.png`;
|
||||
const shootingStar = `${process.env.PUBLIC_URL}/shooting_star_2.png`;
|
||||
const iconMeetGreet = `${process.env.PUBLIC_URL}/icon-meet-greet.png`;
|
||||
const iconSocials = `${process.env.PUBLIC_URL}/icon-socials.png`;
|
||||
const iconActive = `${process.env.PUBLIC_URL}/icon-active.png`;
|
||||
const heroLoaf = `${process.env.PUBLIC_URL}/hero-loaf.png`;
|
||||
const friendships = `${process.env.PUBLIC_URL}/friendships.png`;
|
||||
const InfoCard = ({ iconSrc, infoTitle, description }) => (
|
||||
<Card className="relative bg-background rounded-2xl overflow-visible flex flex-col gap-3.5 items-center pt-16 pb-0 w-full max-w-none lg:max-w-[363px]">
|
||||
<div className="absolute -top-20 md:-top-40 flex justify-center w-full">
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt={infoTitle}
|
||||
className=" w-40 md:w-64 lg:max-w-[330px] h-auto aspect-[10/9] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col pt-10 gap-4.5 w-full">
|
||||
<h5 className="text-[var(--purple-deep)] text-[28px] leading-10 pb-10 font-semibold text-center" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
{infoTitle}
|
||||
</h5>
|
||||
{description}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const infoCardData = [
|
||||
{
|
||||
iconSrc: iconMeetGreet,
|
||||
infoTitle: 'Meet and Greet',
|
||||
description: (
|
||||
<p className="text-[var(--purple-deep)] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The MEET and GREETs provide opportunities for prospective members to get acquainted with LOAF, have conversations
|
||||
with members, and ask the board of directors questions. They are held the 3rd Sunday of the month and usually
|
||||
take place at a restaurant or other fun places conducive to its purpose. Please email{' '}
|
||||
<a href="mailto:info@loaftx.org" className="underline">info@loaftx.org</a> for upcoming times and locations.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
iconSrc: iconSocials,
|
||||
infoTitle: 'Socials',
|
||||
description: (
|
||||
<p className="text-[var(--purple-deep)] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our social events provide opportunities for members to explore Houston and connect with other lesbians. Past
|
||||
social events include bowling, museums, painting lessons, sporting events, Miller Outdoor Theater, bingo and board
|
||||
games, pool parties, putt putt golf, camping and holiday get togethers. No matter your age or ability, there is
|
||||
something for everyone.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
iconSrc: iconActive,
|
||||
infoTitle: 'Active LOAFers',
|
||||
description: (
|
||||
<p className="text-[var(--purple-deep)] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
ActiveLOAFers events provide members with opportunities to be physically active. Past activities have included
|
||||
hiking/walking in the park, swimming (or floating), pickleball, kayaking, bike riding, axe throwing, and strolling
|
||||
through the botanic gardens or the Arboretum.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-b from-[#48286e] to-[#664fa3] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 md:py-12 lg:py-0 flex flex-col lg:flex-row gap-8 md:gap-12 lg:gap-16 items-center justify-center">
|
||||
<div className="py-8 md:py-10 flex flex-col gap-6 sm:gap-8 items-center justify-center w-full lg:w-[420px] lg:flex-shrink-0">
|
||||
<section className="relative bg-gradient-to-b from-[var(--purple-deep)] to-[var(--purple-lavender)] py-20 sm:py-8 md:py-12 lg:py-16 flex flex-col lg:flex-row gap-8 md:gap-12 lg:gap-16 items-center justify-center w-full">
|
||||
{/* Friendships background image */}
|
||||
<div className="absolute inset-0 z-0 flex overflow-hidden top-[-32rem] lg:-top-32">
|
||||
<img src={friendships} alt="Friendships" className="lg:max-w-screen opacity-15 max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
{/* Blur Overlay */}
|
||||
<div className="absolute inset-0 z-[1] bg-background/5 backdrop-blur-xs"></div>
|
||||
{/* Left column Loaf Image */}
|
||||
<div className="relative z-10 lg:py-20 py-7 flex flex-col gap-6 sm:gap-8 items-center justify-center w-full lg:w-[530px] lg:flex-shrink-0">
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
<img src={heroLoaf} alt="LOAF" className="w-full max-w-[334px] h-auto object-contain" />
|
||||
<img src={heroLoaf} alt="LOAF" className="w-full max-w-xs md:max-w-[370px] h-auto object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-center justify-center w-full max-w-[339px]">
|
||||
<Link to="/register" className="w-full">
|
||||
<Button className="bg-[#DDD8EB] hover:bg-white text-[#422268] rounded-full px-6 py-6 sm:py-[32px] text-base sm:text-lg font-medium w-full transition-colors">
|
||||
<Link to="/become-a-member" className="w-full">
|
||||
<Button style={{ fontFamily: "'Nunito sans', sans-serif" }} className="bg-[var(--neutral-800)] hover:bg-background text-[var(--purple-ink)] rounded-full px-6 py-6 sm:py-[32px] text-base sm:text-lg font-medium w-full transition-colors">
|
||||
Become a Member
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -35,74 +98,43 @@ const Landing = () => {
|
||||
LOAF is supported by the Hollyfield Foundation
|
||||
</p>
|
||||
</div>
|
||||
<div className="py-8 md:py-12 lg:py-16 flex items-center justify-center w-full lg:w-[594px] h-auto">
|
||||
<img src={taglineImage} alt="LOAF Tagline" className="w-full max-w-[483px] h-auto object-contain" />
|
||||
{/* Right Column Loaf Tagline */}
|
||||
<div className="relative z-10 py-8 md:py-12 lg:py-16 flex items-center justify-center w-full lg:max-w-[815px] h-auto">
|
||||
<img src={taglineImage} alt="LOAF Tagline" className="relative z-10 w-full h-auto object-cover" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About Section */}
|
||||
<section id="about" className="bg-gradient-to-b from-white to-[#f1eef9] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 pt-12 sm:pt-16 md:pt-20 lg:pt-30 pb-0 flex flex-col gap-6 sm:gap-8">
|
||||
<div className="flex flex-col items-center pt-12">
|
||||
<h3 className="text-[#48286e] text-3xl sm:text-4xl md:text-5xl font-extrabold text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<section id="about" className="bg-gradient-to-b pb-10 lg:pb-44 from-white to-[var(--lavender-300)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 pt-4 sm:pt-16 md:pt-20 lg:pt-30 flex flex-col">
|
||||
<div className="flex flex-col items-center pt-4">
|
||||
<h3 className="text-[var(--purple-deep)] px-4 pb-6 md:py-8 text-4xl leading-[60px] md:text-5xl lg:text-6xl font-extrabold text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome to LOAF
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[rgba(0,0,0,0.55)] text-lg text-center font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-[rgba(0,0,0,0.55)] text-lg lg:text-2xl text-center font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
LOAF is Houston's social networking group for lesbians who are 50 years of age and older. LOAF hosts three main activities each month, Meet and Greets, Socials, and ActiveLOAFers. TheaterLOAFers coordinate events throughout the year.
|
||||
</p>
|
||||
<img src={shootingStar} alt="Decorative element" className="w-full h-[197px] object-contain" />
|
||||
</section>
|
||||
|
||||
{/* Feature Cards Section */}
|
||||
<section className="bg-gradient-to-b from-[#f1eef9] to-[#ddd8eb] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12 sm:py-16 md:py-20 lg:py-30 flex flex-col md:flex-row gap-6 sm:gap-8 items-start justify-center">
|
||||
<Card className="bg-white rounded-2xl overflow-hidden flex flex-col gap-3.5 items-center pt-5 pb-0 w-full max-w-[363px]">
|
||||
<img src={iconMeetGreet} alt="Meet and Greet" className="w-full max-w-[300px] h-auto aspect-[10/9] object-contain" />
|
||||
<div className="p-6 flex flex-col gap-4.5 w-full">
|
||||
<h5 className="text-[#48286e] text-2xl font-semibold text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Meet and Greet
|
||||
</h5>
|
||||
<p className="text-[#48286e] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The MEET and GREETs provide opportunities for prospective members to get acquainted with LOAF, have conversations with members, and ask the board of directors questions. They are held the 3rd Sunday of the month and usually take place at a restaurant or other fun places conducive to its purpose. Please email{' '}
|
||||
<a href="mailto:info@loaftx.org" className="underline">info@loaftx.org</a> for upcoming times and locations.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white rounded-2xl overflow-hidden flex flex-col gap-3.5 items-center pt-5 pb-0 w-full max-w-[363px]">
|
||||
<img src={iconSocials} alt="Socials" className="w-full max-w-[300px] h-auto aspect-[10/9] object-contain" />
|
||||
<div className="p-6 flex flex-col gap-4.5 w-full">
|
||||
<h5 className="text-[#48286e] text-2xl font-semibold text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Socials
|
||||
</h5>
|
||||
<p className="text-[#48286e] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our social events provide opportunities for members to explore Houston and connect with other lesbians. Past social events include, bowling, museums, painting lessons, sporting events, Miller Outdoor Theater, bingo and board games, pool parties, putt putt golf, camping and holiday get togethers. No matter your age or ability, there is something for everyone.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white rounded-2xl overflow-hidden flex flex-col gap-3.5 items-center pt-5 pb-0 w-full max-w-[363px]">
|
||||
<img src={iconActive} alt="Active LOAFers" className="w-full max-w-[300px] h-auto aspect-[10/9] object-contain" />
|
||||
<div className="p-6 flex flex-col gap-4.5 w-full">
|
||||
<h5 className="text-[#48286e] text-2xl font-semibold text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Active LOAFers
|
||||
</h5>
|
||||
<p className="text-[#48286e] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
ActiveLOAFers events provide members with opportunities to be physically active. Past activities have included, hiking/walking in the park, swimming (or floating), pickleball, kayaking, bike riding, axe throwing, and strolling through the botanic gardens or the Arboretum.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<section className="bg-gradient-to-b pb-20 from-[var(--lavender-300)] to-[var(--neutral-800)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-4 md:py-20 lg:py-30 flex flex-col sm:w-full lg:flex-row gap-40 md:gap-64 lg:gap-8 items-stretch justify-center">
|
||||
{infoCardData.map((card) => (
|
||||
<InfoCard key={card.infoTitle} {...card} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-gradient-to-b from-[#644c9f] to-[#48286e] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12 sm:py-16 md:py-20 lg:py-30 flex items-center justify-center">
|
||||
<div className="flex flex-col lg:flex-row gap-8 sm:gap-10 md:gap-12 items-center justify-center w-full max-w-6xl">
|
||||
<Link to="/register" className="w-full sm:w-auto">
|
||||
<Button className="bg-[#DDD8EB] hover:bg-white text-[#422268] rounded-full px-6 py-6 sm:py-[32px] text-base sm:text-lg font-medium w-full sm:w-[392px] transition-colors">
|
||||
<section className="bg-gradient-to-b from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12 sm:py-16 md:py-20 lg:py-30 flex items-center justify-center">
|
||||
<div className="flex flex-col-reverse md:flex-col lg:flex-row gap-8 sm:gap-10 md:gap-12 items-center justify-center w-full max-w-6xl">
|
||||
<Link to="/register" className="w-full sm:w-auto flex items-center justify-center">
|
||||
<Button className="bg-[var(--neutral-800)] hover:bg-background text-[var(--purple-ink)] rounded-full
|
||||
py-8 text-xl font-normal px-12 sm:w-[392px] transition-colors ">
|
||||
Become a Member
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<h4 className="text-white text-2xl sm:text-3xl md:text-4xl font-bold text-center lg:text-left max-w-[718px]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h4 className="text-white text-3xl px-4 font-bold text-center lg:text-left leading-normal max-w-[718px]" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
No matter your age or ability, there is something for everyone.
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ const Login = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role === 'admin') {
|
||||
if (user.role === 'admin' || user.role === 'superadmin') {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
@@ -55,23 +55,23 @@ const Login = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
<Link to="/" className="inline-flex items-center text-[#664fa3] hover:text-[#ff9e77] transition-colors">
|
||||
<Link to="/" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] 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-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome Back
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Login to access your member dashboard.
|
||||
</p>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@ const Login = () => {
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="your.email@example.com"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 focus:border-brand-purple "
|
||||
data-testid="login-email-input "
|
||||
/>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@ const Login = () => {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link to="/forgot-password" className="text-sm text-[#ff9e77] hover:underline">
|
||||
<Link to="/forgot-password" className="text-sm text-[var(--orange-light)] hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
@@ -106,7 +106,7 @@ const Login = () => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter your password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="login-password-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -114,16 +114,16 @@ const Login = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
className="w-full py-6 text-lg font-medium shadow-lg hover:scale-105 disabled:opacity-50 btn-lavender"
|
||||
data-testid="login-submit-button"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-[#ff9e77] hover:underline font-medium">
|
||||
<Link to="/register" className="text-[var(--orange-light)] hover:underline font-medium">
|
||||
Register here
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -7,36 +7,34 @@ const MissionValues = () => {
|
||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-[#f9fafb] to-[#ddd8eb] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-12 md:py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<main className="bg-gradient-to-b from-[var(--neutral-100:)] to-[var(--neutral-800)] px-4 sm:px-6 py-8 sm:py-12 md:py-20">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="flex md:flex-row flex-col gap-10 items-stretch">
|
||||
{/* Left Card - Mission (Purple Gradient) */}
|
||||
<Card className="bg-gradient-to-br from-[#664fa3] to-[#48286e] p-8 rounded-2xl shadow-lg">
|
||||
<Card className=" bg-gradient-to-br from-[var(--purple-lavender)] to-[var(--purple-deep)] p-16 rounded-2xl shadow-lg flex flex-col items-center justify-between flex-1 w-full md:w-1/2 ">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white text-center mb-6"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF Mission
|
||||
</h2>
|
||||
<p className="text-white text-lg text-center leading-relaxed"
|
||||
<p className="text-white text-xl text-center leading-relaxed"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF's mission is to alleviate isolation and enrich the lives of lesbians
|
||||
over the age of 50 by providing several social networking events every month
|
||||
in Houston and the surrounding areas.
|
||||
LOAF’s mission is to alleviate isolation and enrich the lives of lesbians over the age of 50 by providing several social networking events every month in Houston and the surrounding areas.
|
||||
</p>
|
||||
<div className="flex justify-center mb-6">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 lg:w-64 lg:h-64 object-contain" />
|
||||
<img src={loafLogo} alt="LOAF Logo" className="size-32 sm:size-40 md:size-64 lg:size-96 object-contain" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right Card - Values */}
|
||||
<Card className="bg-white p-8 rounded-2xl shadow-lg">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] text-center mb-6"
|
||||
<Card className="bg-background p-16 rounded-2xl shadow-lg flex-1 w-full md:w-1/2 ">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[var(--purple-deep)] text-center mb-6"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF Values
|
||||
</h2>
|
||||
<ol className="list-decimal list-inside space-y-3 text-lg text-[#48286e]"
|
||||
<ol className="list-decimal list-inside space-y-8 text-lg text-[var(--purple-deep)]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li>Safe environments for lesbians to gather for a variety of social activities and interaction.</li>
|
||||
<li>Social support for lesbians.</li>
|
||||
|
||||
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-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl p-12 bg-background rounded-2xl border border-[var(--neutral-800)] 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-[var(--neutral-800)] to-[var(--lavender-700)] 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-brand-purple opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<h2
|
||||
className="text-3xl font-semibold text-[var(--purple-ink)] mb-4"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p
|
||||
className="text-lg text-brand-purple 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-brand-purple text-brand-purple hover:bg-[var(--lavender-700)] 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-brand-purple to-[var(--purple-ink)] hover:from-[var(--purple-ink)] hover:to-brand-purple 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-[var(--neutral-800)]">
|
||||
<p
|
||||
className="text-sm text-brand-purple "
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Need help? Contact us at{' '}
|
||||
<a
|
||||
href="mailto:support@loaftx.org"
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
|
||||
>
|
||||
support@loaftx.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -9,7 +9,7 @@ const PaymentCancel = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
@@ -22,49 +22,49 @@ const PaymentCancel = () => {
|
||||
</div>
|
||||
|
||||
{/* Cancel Message */}
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Cancelled
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#ddd8eb] shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
What Happened?
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 mb-8">
|
||||
<p className="text-[#664fa3] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You cancelled the payment process or closed the checkout page. Your membership has not been activated yet.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#DDD8EB]/20 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="bg-[var(--neutral-800)]/20 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
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-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CreditCard className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Mail className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Contact us if you experienced any issues during checkout
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f1eef9] p-6 rounded-xl">
|
||||
<p className="text-sm text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[#422268]">Note:</span>{' '}
|
||||
Your membership application is still approved. You can complete payment whenever you're ready.
|
||||
<div className="bg-[var(--lavender-300)] p-6 rounded-xl">
|
||||
<p className="text-sm text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[var(--purple-ink)]">Note:</span>{' '}
|
||||
Your membership application is still validated. You can complete payment whenever you're ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@ const PaymentCancel = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
onClick={() => navigate('/plans')}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-semibold"
|
||||
data-testid="try-again-button"
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
@@ -82,7 +82,7 @@ const PaymentCancel = () => {
|
||||
<Button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple 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" />
|
||||
@@ -92,17 +92,17 @@ const PaymentCancel = () => {
|
||||
</Card>
|
||||
|
||||
{/* Support Section */}
|
||||
<Card className="p-6 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border border-[#ddd8eb]">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-3 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border border-[var(--neutral-800)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Need Assistance?
|
||||
</h3>
|
||||
<p className="text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#ff9e77] hover:text-[#664fa3] font-medium text-lg"
|
||||
className="text-[var(--orange-light)] hover:text-brand-purple font-medium text-lg"
|
||||
>
|
||||
support@loaf.org
|
||||
</a>
|
||||
|
||||
@@ -20,60 +20,60 @@ const PaymentSuccess = () => {
|
||||
}, [refreshUser]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<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">
|
||||
<div className="bg-[var(--green-light)] 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 text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Successful!
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#ddd8eb] shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome to the LOAF Community!
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 mb-8">
|
||||
<div className="bg-[#f1eef9] p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="bg-[var(--lavender-300)] p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You'll receive our newsletter with exclusive updates and announcements
|
||||
</span>
|
||||
</li>
|
||||
@@ -81,12 +81,12 @@ const PaymentSuccess = () => {
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<div className="bg-[#DDD8EB]/20 p-4 rounded-xl">
|
||||
<p className="text-sm text-[#664fa3] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[#422268]">Transaction ID:</span>{' '}
|
||||
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-xl">
|
||||
<p className="text-sm text-brand-purple text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[var(--purple-ink)]">Transaction ID:</span>{' '}
|
||||
<span className="font-mono text-xs">{sessionId}</span>
|
||||
</p>
|
||||
<p className="text-xs text-[#664fa3] text-center mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-brand-purple text-center mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
A confirmation email has been sent to your registered email address.
|
||||
</p>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ const PaymentSuccess = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-semibold"
|
||||
data-testid="go-to-dashboard-button"
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
@@ -106,7 +106,7 @@ const PaymentSuccess = () => {
|
||||
<Button
|
||||
onClick={() => navigate('/events')}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple 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" />
|
||||
@@ -117,11 +117,11 @@ const PaymentSuccess = () => {
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Need help? Contact us at{' '}
|
||||
<a
|
||||
href="mailto:support@loaf.org"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3] font-medium"
|
||||
className="text-[var(--orange-light)] hover:text-brand-purple font-medium"
|
||||
>
|
||||
support@loaf.org
|
||||
</a>
|
||||
|
||||
@@ -47,16 +47,16 @@ const Plans = () => {
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
},
|
||||
pending_approval: {
|
||||
pending_validation: {
|
||||
title: "Application Under Review",
|
||||
message: "Your application is being reviewed by our admin team. You'll receive an email once approved to proceed with payment.",
|
||||
message: "Your application is being reviewed by our admin team. You'll receive an email once validated to proceed with payment.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
},
|
||||
pre_approved: {
|
||||
pre_validated: {
|
||||
title: "Application Under Review",
|
||||
message: "Your application is being reviewed by our admin team. You'll receive an email once approved to proceed with payment.",
|
||||
message: "Your application is being reviewed by our admin team. You'll receive an email once validated to proceed with payment.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
@@ -77,10 +77,31 @@ const Plans = () => {
|
||||
},
|
||||
inactive: {
|
||||
title: "Membership Inactive",
|
||||
message: "Your membership has expired. Please select a plan below to renew your membership.",
|
||||
message: "Your membership is currently inactive. Please contact support for assistance.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
},
|
||||
canceled: {
|
||||
title: "Membership Canceled",
|
||||
message: "Your membership was canceled. You can rejoin by selecting a plan below.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: true
|
||||
},
|
||||
expired: {
|
||||
title: "Membership Expired",
|
||||
message: "Your membership has expired. Please renew by selecting a plan below.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: true
|
||||
},
|
||||
abandoned: {
|
||||
title: "Application Incomplete",
|
||||
message: "Your application was not completed. Please contact support to restart the registration process.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,36 +208,36 @@ const Plans = () => {
|
||||
const breakdown = getAmountBreakdown();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<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 text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Membership Plans
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] max-w-2xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple max-w-2xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Choose the membership plan that works best for you and become part of our vibrant community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Banner */}
|
||||
{statusInfo && statusInfo.title && (
|
||||
<Card className="max-w-3xl mx-auto mb-8 p-6 bg-gradient-to-r from-[#f1eef9] to-[#DDD8EB]/30 border-2 border-[#664fa3]">
|
||||
<Card className="max-w-3xl mx-auto mb-8 p-6 bg-gradient-to-r from-[var(--lavender-300)] to-[var(--neutral-800)]/30 border-2 border-brand-purple ">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="h-6 w-6 text-[#664fa3] flex-shrink-0 mt-1" />
|
||||
<AlertCircle className="h-6 w-6 text-brand-purple flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{statusInfo.title}
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{statusInfo.message}
|
||||
</p>
|
||||
{statusInfo.action && statusInfo.actionLink && (
|
||||
<Button
|
||||
onClick={() => navigate(statusInfo.actionLink)}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full"
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full"
|
||||
>
|
||||
{statusInfo.action}
|
||||
</Button>
|
||||
@@ -228,11 +249,16 @@ const Plans = () => {
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<Loader2 className="h-12 w-12 text-[#664fa3] mx-auto mb-4 animate-spin" />
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||
<Loader2 className="h-12 w-12 text-brand-purple mx-auto mb-4 animate-spin" />
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||
</div>
|
||||
) : 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) => {
|
||||
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
||||
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
||||
@@ -240,19 +266,19 @@ const Plans = () => {
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className="p-8 bg-white rounded-2xl border-2 border-[#ddd8eb] hover:border-[#664fa3] hover:shadow-xl transition-all"
|
||||
className="p-8 bg-background rounded-2xl border-2 border-[var(--neutral-800)] hover:border-brand-purple hover:shadow-xl transition-all"
|
||||
data-testid={`plan-card-${plan.id}`}
|
||||
>
|
||||
{/* Plan Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="bg-[#DDD8EB]/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-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/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-brand-purple " />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{plan.name}
|
||||
</h2>
|
||||
{plan.description && (
|
||||
<p className="text-sm text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -260,22 +286,22 @@ const Plans = () => {
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Starting at
|
||||
</div>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(minimumPrice)}
|
||||
</div>
|
||||
{suggestedPrice > minimumPrice && (
|
||||
<div className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Suggested: {formatPrice(suggestedPrice)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{getBillingCycleLabel(plan.billing_cycle)}
|
||||
</p>
|
||||
{plan.allow_donation && (
|
||||
<div className="mt-2 flex items-center justify-center gap-1 text-xs text-[#ff9e77]">
|
||||
<div className="mt-2 flex items-center justify-center gap-1 text-xs text-[var(--orange-light)]">
|
||||
<Heart className="h-3 w-3" />
|
||||
<span>Donations welcome</span>
|
||||
</div>
|
||||
@@ -285,20 +311,20 @@ const Plans = () => {
|
||||
{/* 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-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Access to all member events</span>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Community directory access</span>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Exclusive member benefits</span>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Newsletter subscription</span>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Newsletter subscription</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -306,7 +332,7 @@ const Plans = () => {
|
||||
<Button
|
||||
onClick={() => handleSelectPlan(plan)}
|
||||
disabled={processingPlanId === plan.id || (statusInfo && !statusInfo.canSubscribe)}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid={`subscribe-button-${plan.id}`}
|
||||
>
|
||||
{processingPlanId === plan.id ? (
|
||||
@@ -315,7 +341,7 @@ const Plans = () => {
|
||||
Processing...
|
||||
</>
|
||||
) : statusInfo && !statusInfo.canSubscribe ? (
|
||||
'Approval Required'
|
||||
'Validation Required'
|
||||
) : (
|
||||
'Choose Amount & Subscribe'
|
||||
)}
|
||||
@@ -326,11 +352,11 @@ const Plans = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<CreditCard className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CreditCard className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Plans Available
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Membership plans are not currently available. Please check back later!
|
||||
</p>
|
||||
</div>
|
||||
@@ -338,17 +364,17 @@ const Plans = () => {
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-16 max-w-3xl mx-auto">
|
||||
<Card className="p-8 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border border-[#ddd8eb]">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-4 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border border-[var(--neutral-800)]">
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Need Help Choosing?
|
||||
</h3>
|
||||
<p className="text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#ff9e77] hover:text-[#664fa3] font-medium"
|
||||
className="text-[var(--orange-light)] hover:text-brand-purple font-medium"
|
||||
>
|
||||
support@loaf.org
|
||||
</a>
|
||||
@@ -361,10 +387,10 @@ const Plans = () => {
|
||||
<Dialog open={amountDialogOpen} onOpenChange={setAmountDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Choose Your Amount
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedPlan?.name} - {getBillingCycleLabel(selectedPlan?.billing_cycle)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -372,11 +398,11 @@ const Plans = () => {
|
||||
<div className="space-y-6">
|
||||
{/* Amount Input */}
|
||||
<div>
|
||||
<Label htmlFor="amount" className="text-[#422268]">
|
||||
<Label htmlFor="amount" className="text-[var(--purple-ink)]">
|
||||
Amount (USD) *
|
||||
</Label>
|
||||
<div className="relative mt-2">
|
||||
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-[#664fa3] text-lg font-semibold">
|
||||
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-brand-purple text-lg font-semibold">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
@@ -386,25 +412,25 @@ const Plans = () => {
|
||||
min={selectedPlan ? (selectedPlan.minimum_price_cents / 100).toFixed(2) : "30.00"}
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
className="pl-8 h-14 text-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="pl-8 h-14 text-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="50.00"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Minimum: {selectedPlan ? formatPrice(selectedPlan.minimum_price_cents || 3000) : '$30.00'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Breakdown Display */}
|
||||
{breakdown && breakdown.total >= breakdown.base && (
|
||||
<Card className="p-4 bg-[#f9f5ff] border border-[#DDD8EB]">
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border border-[var(--neutral-800)]">
|
||||
<div className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex justify-between text-[#422268]">
|
||||
<div className="flex justify-between text-[var(--purple-ink)]">
|
||||
<span>Membership Fee:</span>
|
||||
<span className="font-semibold">{formatPrice(breakdown.base)}</span>
|
||||
</div>
|
||||
{breakdown.donation > 0 && (
|
||||
<div className="flex justify-between text-[#ff9e77]">
|
||||
<div className="flex justify-between text-[var(--orange-light)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4" />
|
||||
Additional Donation:
|
||||
@@ -412,7 +438,7 @@ const Plans = () => {
|
||||
<span className="font-semibold">{formatPrice(breakdown.donation)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-[#422268] font-bold text-base pt-2 border-t border-[#DDD8EB]">
|
||||
<div className="flex justify-between text-[var(--purple-ink)] font-bold text-base pt-2 border-t border-[var(--neutral-800)]">
|
||||
<span>Total:</span>
|
||||
<span>{formatPrice(breakdown.total)}</span>
|
||||
</div>
|
||||
@@ -422,8 +448,8 @@ const Plans = () => {
|
||||
|
||||
{/* Donation Message */}
|
||||
{selectedPlan?.allow_donation && (
|
||||
<div className="bg-[#DDD8EB]/20 rounded-lg p-4">
|
||||
<p className="text-sm text-[#422268] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="bg-[var(--neutral-800)]/20 rounded-lg p-4">
|
||||
<p className="text-sm text-[var(--purple-ink)] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Thank you for supporting our community!</strong><br />
|
||||
Your donation helps us continue our mission and provide meaningful experiences for all members.
|
||||
</p>
|
||||
@@ -443,7 +469,7 @@ const Plans = () => {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCheckout}
|
||||
className="flex-1 bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
className="flex-1 bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
|
||||
>
|
||||
Continue to Checkout
|
||||
</Button>
|
||||
|
||||
197
src/pages/PrivacyPolicy.js
Normal file
197
src/pages/PrivacyPolicy.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<>
|
||||
<PublicNavbar />
|
||||
<main className="bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] text-[var(--purple-deep)]">
|
||||
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||
<header className="border-b pb-6">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight" style={{ fontFamily: 'Poppins' }}>
|
||||
LOAFers, Inc. Website Privacy Policy
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="prose text-[var(--purple-deep)] max-w-none prose-h2:mt-10 prose-h2:scroll-mt-24 prose-h3:mt-6">
|
||||
<section className="mt-8">
|
||||
<p>
|
||||
This Privacy Policy ("Policy") applies to Membership Applications, and LOAFers, Inc. ("Company") and
|
||||
governs data collection and usage. The Company's application is a Membership request, Membership online
|
||||
profile, and Consent to receive eNewsletters. By using the Company application, you consent to the data
|
||||
practices described in the statement.
|
||||
</p>
|
||||
<p>
|
||||
We reserve the right to change this policy at any given time, of which you will be promptly updated. If
|
||||
you want to make sure that you are up to date with the latest changes, we advise you to frequently visit
|
||||
this page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="user-data" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">What User Data We Collect</h2>
|
||||
<p>When you visit the Site, we may collect the following data:</p>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>Your IP address.</li>
|
||||
<li>Your contact information and email address.</li>
|
||||
</ul>
|
||||
|
||||
<p>When you apply for membership, we collect the following data:</p>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>First and last name</li>
|
||||
<li>Mailing address</li>
|
||||
<li>Email</li>
|
||||
<li>Phone number</li>
|
||||
<li>Birthday</li>
|
||||
</ul>
|
||||
|
||||
<p>If you choose to pay your membership administrative fee online, we have access to:</p>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>Partial credit card information</li>
|
||||
</ul>
|
||||
|
||||
<p>You may also choose to provide the following:</p>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>Partners name</li>
|
||||
<li>Photo</li>
|
||||
<li>Self-bio</li>
|
||||
<li>Consent to receive our eNewsletter</li>
|
||||
<li>Consent to display an online profile visible only to membership</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="why-collect" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Why We Collect Your Data</h2>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>
|
||||
To send you announcement emails containing the information about our events and information we think you
|
||||
will find interesting.
|
||||
</li>
|
||||
<li>To contact you to fill out surveys about our membership</li>
|
||||
<li>To customize our blog according to your online behavior and personal preferences.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="third-parties" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Sharing Information with Third Parties</h2>
|
||||
<p>The Company does not sell, rent, or lease personal data to third parties.</p>
|
||||
<p>
|
||||
The Company may share data with trusted partners to help perform statistical analysis, provide customer
|
||||
support.
|
||||
</p>
|
||||
<p>
|
||||
The Company uses Stripe to process online payments at which time users would no longer be governed by the
|
||||
Company's Privacy Policy.
|
||||
</p>
|
||||
<p>The Company may disclose your personal information, without notice, if required to do so by law.</p>
|
||||
</section>
|
||||
|
||||
<section id="safeguarding" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Safeguarding and Securing the Data</h2>
|
||||
<p>
|
||||
LOAFers, Inc. is committed to securing your data and keeping it confidential. LOAFers, Inc. has done all
|
||||
in its power to prevent data theft, unauthorized access, and disclosure by implementing the latest
|
||||
technologies and software, which help us safeguard all the information we collect online.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="cookies" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Our Cookie Policy</h2>
|
||||
<p>
|
||||
Once you agree to allow our blog to use cookies, you also agree to use the data it collects regarding your
|
||||
online behavior (analyze web traffic, web pages you visit and spend the most time on).
|
||||
</p>
|
||||
<p>
|
||||
The data we collect by using cookies is used to customize our blog to your needs. After we use the data
|
||||
for statistical analysis, the data is completely removed from our systems.
|
||||
</p>
|
||||
<p>
|
||||
Please note that cookies don't allow us to gain control of your computer in any way. They are strictly
|
||||
used to monitor which pages you find useful and which you do not so that we can provide a better
|
||||
experience for you.
|
||||
</p>
|
||||
<p>
|
||||
If you want to disable cookies, you can do it by accessing the settings of your internet browser. You can
|
||||
visit{" "}
|
||||
<a className="" href="https://www.internetcookies.com">
|
||||
https://www.internetcookies.com
|
||||
</a>
|
||||
, which contains comprehensive information on how to do this on a wide variety of browsers and devices.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="other-sites" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Links to Other Websites</h2>
|
||||
<p>
|
||||
Our blog contains links that lead to other websites. If you click on these links LOAFers, Inc. is not held
|
||||
responsible for your data and privacy protection. Visiting those websites is not governed by this privacy
|
||||
policy agreement. Make sure to read the privacy policy documentation of the website you go to from our
|
||||
website.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="restricting" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">
|
||||
Restricting the Collection of your Personal Data
|
||||
</h2>
|
||||
<p>
|
||||
At some point, you might wish to restrict the use and collection of your personal data. You can achieve
|
||||
this by doing the following:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>Log in to your online profile and make any changes you wish to your profile information.</li>
|
||||
<li>
|
||||
If you have already agreed to share your information with us, feel free to contact us via email and we
|
||||
will be more than happy to change this for you.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="children" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Children Under Thirteen</h2>
|
||||
<p>The Company does not knowingly collect information from children under the age of 13.</p>
|
||||
</section>
|
||||
|
||||
<section id="changes" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Changes to this Statement</h2>
|
||||
<p>
|
||||
The Company may make changes to this Policy. When this occurs the effective date of this policy will be
|
||||
updated.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="contact" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Contact Information</h2>
|
||||
<p>If you have any question, please contact LOAFers, Inc. at:</p>
|
||||
<div className="not-prose mt-4">
|
||||
<p className="font-semibold mb-2">LOAFers, Inc.</p>
|
||||
<p className="">PO BOX 7207</p>
|
||||
<p className="">Houston, TX 77248-7207</p>
|
||||
<p className="mt-3">Or</p>
|
||||
<p className="mt-3">
|
||||
<a className="" href="mailto:info@loaftx.org">
|
||||
info@loaftx.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Link */}
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-[var(--purple-lavender)] hover:text-[var(--purple-ink)] font-semibold transition-colors inline-flex items-center gap-2"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
<span>←</span> Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<PublicFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../utils/api';
|
||||
import { Card } from '../components/ui/card';
|
||||
@@ -9,14 +9,24 @@ import { Textarea } from '../components/ui/textarea';
|
||||
import { toast } from 'sonner';
|
||||
import Navbar from '../components/Navbar';
|
||||
import MemberFooter from '../components/MemberFooter';
|
||||
import { User, Save, Lock, Heart, Users, Mail, BookUser } from 'lucide-react';
|
||||
import { User, Save, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2 } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
|
||||
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
||||
import TransactionHistory from '../components/TransactionHistory';
|
||||
|
||||
const Profile = () => {
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [profileData, setProfileData] = useState(null);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [profilePhotoUrl, setProfilePhotoUrl] = useState(null);
|
||||
const [previewImage, setPreviewImage] = useState(null);
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); // Default 50MB
|
||||
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); // Default 50MB in bytes
|
||||
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
|
||||
const [transactionsLoading, setTransactionsLoading] = useState(true);
|
||||
const [formData, setFormData] = useState({
|
||||
// Personal Information
|
||||
first_name: '',
|
||||
@@ -49,13 +59,28 @@ const Profile = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchProfile();
|
||||
fetchTransactions();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await api.get('/config');
|
||||
setMaxFileSizeMB(response.data.max_file_size_mb);
|
||||
setMaxFileSizeBytes(response.data.max_file_size_bytes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config, using defaults:', error);
|
||||
// Keep default values if fetch fails
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await api.get('/users/profile');
|
||||
setProfileData(response.data);
|
||||
setProfilePhotoUrl(response.data.profile_photo_url);
|
||||
setPreviewImage(response.data.profile_photo_url);
|
||||
setFormData({
|
||||
// Personal Information
|
||||
first_name: response.data.first_name || '',
|
||||
@@ -91,6 +116,19 @@ const Profile = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
setTransactionsLoading(true);
|
||||
const response = await api.get('/members/transactions');
|
||||
setTransactions(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
// Don't show error toast - transactions are optional
|
||||
} finally {
|
||||
setTransactionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
@@ -124,6 +162,57 @@ const Profile = () => {
|
||||
'Hospitality'
|
||||
];
|
||||
|
||||
const handlePhotoUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSizeBytes) {
|
||||
toast.error(`File size must be less than ${maxFileSizeMB}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingPhoto(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post('/members/profile/upload-photo', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
setProfilePhotoUrl(response.data.profile_photo_url);
|
||||
setPreviewImage(response.data.profile_photo_url);
|
||||
toast.success('Profile photo updated successfully!');
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload photo');
|
||||
} finally {
|
||||
setUploadingPhoto(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoDelete = async () => {
|
||||
if (!profilePhotoUrl) return;
|
||||
|
||||
setUploadingPhoto(true);
|
||||
try {
|
||||
await api.delete('/members/profile/delete-photo');
|
||||
setProfilePhotoUrl(null);
|
||||
setPreviewImage(null);
|
||||
toast.success('Profile photo deleted successfully!');
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete photo');
|
||||
} finally {
|
||||
setUploadingPhoto(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -144,7 +233,7 @@ const Profile = () => {
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -156,37 +245,37 @@ const Profile = () => {
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
My Profile
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Update your personal information below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
{/* Read-only Information */}
|
||||
<div className="mb-8 pb-8 border-b border-[#ddd8eb]">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<User className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="mb-8 pb-8 border-b border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<User className="h-6 w-6 text-brand-purple " />
|
||||
Account Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Status</p>
|
||||
<p className="text-[#422268] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.status.replace('_', ' ')}</p>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Status</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.status.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
|
||||
<p className="text-[#422268] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.role}</p>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.role}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(profileData.date_of_birth).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -197,7 +286,7 @@ const Profile = () => {
|
||||
type="button"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-6 py-3"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6 py-3"
|
||||
>
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Change Password
|
||||
@@ -205,9 +294,62 @@ const Profile = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Photo Section */}
|
||||
<div className="pb-8 mb-8 border-b border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Camera className="h-6 w-6 text-brand-purple " />
|
||||
Profile Photo
|
||||
</h2>
|
||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
||||
<Avatar className="h-32 w-32 border-4 border-[var(--neutral-800)]">
|
||||
<AvatarImage src={previewImage} alt="Profile" />
|
||||
<AvatarFallback className="bg-[var(--lavender-300)] text-brand-purple text-3xl">
|
||||
{profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingPhoto}
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6 py-3"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
|
||||
</Button>
|
||||
|
||||
{profilePhotoUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePhotoDelete}
|
||||
disabled={uploadingPhoto}
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-6 py-3"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Photo
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Upload a profile photo (Max {maxFileSizeMB}MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6" data-testid="profile-form">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Personal Information
|
||||
</h2>
|
||||
|
||||
@@ -219,7 +361,7 @@ const Profile = () => {
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="first-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -230,7 +372,7 @@ const Profile = () => {
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="last-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -244,7 +386,7 @@ const Profile = () => {
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="phone-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -256,7 +398,7 @@ const Profile = () => {
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="address-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -269,7 +411,7 @@ const Profile = () => {
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="city-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -280,7 +422,7 @@ const Profile = () => {
|
||||
name="state"
|
||||
value={formData.state}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="state-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -291,16 +433,16 @@ const Profile = () => {
|
||||
name="zipcode"
|
||||
value={formData.zipcode}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="zipcode-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Partner Information */}
|
||||
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Heart className="h-6 w-6 text-[#ff9e77]" />
|
||||
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Heart className="h-6 w-6 text-[var(--orange-light)]" />
|
||||
Partner Information
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
@@ -312,7 +454,7 @@ const Profile = () => {
|
||||
name="partner_first_name"
|
||||
value={formData.partner_first_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
@@ -323,7 +465,7 @@ const Profile = () => {
|
||||
name="partner_last_name"
|
||||
value={formData.partner_last_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
@@ -335,10 +477,11 @@ const Profile = () => {
|
||||
id="partner_is_member"
|
||||
name="partner_is_member"
|
||||
checked={formData.partner_is_member}
|
||||
accent-color="var(--brand-white)"
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="ui-checkbox "
|
||||
/>
|
||||
<Label htmlFor="partner_is_member" className="cursor-pointer text-[#422268]">
|
||||
<Label htmlFor="partner_is_member" className="cursor-pointer text-[var(--purple-ink)] ">
|
||||
My partner is a current member
|
||||
</Label>
|
||||
</div>
|
||||
@@ -349,9 +492,9 @@ const Profile = () => {
|
||||
name="partner_plan_to_become_member"
|
||||
checked={formData.partner_plan_to_become_member}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="ui-checkbox "
|
||||
/>
|
||||
<Label htmlFor="partner_plan_to_become_member" className="cursor-pointer text-[#422268]">
|
||||
<Label htmlFor="partner_plan_to_become_member" className="cursor-pointer text-[var(--purple-ink)]">
|
||||
My partner plans to become a member
|
||||
</Label>
|
||||
</div>
|
||||
@@ -360,12 +503,12 @@ const Profile = () => {
|
||||
</div>
|
||||
|
||||
{/* Section 3: Newsletter Preferences */}
|
||||
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Mail className="h-6 w-6 text-[#81B29A]" />
|
||||
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Mail className="h-6 w-6 text-[var(--green-light)]" />
|
||||
Newsletter Preferences
|
||||
</h2>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Choose what information you'd like published in our member newsletter.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
@@ -376,9 +519,9 @@ const Profile = () => {
|
||||
name="newsletter_publish_name"
|
||||
checked={formData.newsletter_publish_name}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="ui-checkbox "
|
||||
/>
|
||||
<Label htmlFor="newsletter_publish_name" className="cursor-pointer text-[#422268]">
|
||||
<Label htmlFor="newsletter_publish_name" className="cursor-pointer text-[var(--purple-ink)]">
|
||||
Publish my name
|
||||
</Label>
|
||||
</div>
|
||||
@@ -389,9 +532,9 @@ const Profile = () => {
|
||||
name="newsletter_publish_photo"
|
||||
checked={formData.newsletter_publish_photo}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="ui-checkbox"
|
||||
/>
|
||||
<Label htmlFor="newsletter_publish_photo" className="cursor-pointer text-[#422268]">
|
||||
<Label htmlFor="newsletter_publish_photo" className="cursor-pointer text-[var(--purple-ink)]">
|
||||
Publish my photo
|
||||
</Label>
|
||||
</div>
|
||||
@@ -402,9 +545,9 @@ const Profile = () => {
|
||||
name="newsletter_publish_birthday"
|
||||
checked={formData.newsletter_publish_birthday}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="ui-checkbox"
|
||||
/>
|
||||
<Label htmlFor="newsletter_publish_birthday" className="cursor-pointer text-[#422268]">
|
||||
<Label htmlFor="newsletter_publish_birthday" className="cursor-pointer text-[var(--purple-ink)]">
|
||||
Publish my birthday
|
||||
</Label>
|
||||
</div>
|
||||
@@ -415,9 +558,9 @@ const Profile = () => {
|
||||
name="newsletter_publish_none"
|
||||
checked={formData.newsletter_publish_none}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="ui-checkbox"
|
||||
/>
|
||||
<Label htmlFor="newsletter_publish_none" className="cursor-pointer text-[#422268]">
|
||||
<Label htmlFor="newsletter_publish_none" className="cursor-pointer text-[var(--purple-ink)]">
|
||||
Do not publish any information
|
||||
</Label>
|
||||
</div>
|
||||
@@ -425,12 +568,12 @@ const Profile = () => {
|
||||
</div>
|
||||
|
||||
{/* Section 4: Volunteer Interests */}
|
||||
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Users className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Users className="h-6 w-6 text-brand-purple " />
|
||||
Volunteer Interests
|
||||
</h2>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Select areas where you'd like to volunteer and help our community.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
@@ -441,11 +584,11 @@ const Profile = () => {
|
||||
id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
|
||||
checked={formData.volunteer_interests.includes(option)}
|
||||
onChange={() => handleVolunteerToggle(option)}
|
||||
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="ui-checkbox "
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
|
||||
className="cursor-pointer text-[#422268]"
|
||||
className="cursor-pointer text-[var(--purple-ink)]"
|
||||
>
|
||||
{option}
|
||||
</Label>
|
||||
@@ -455,32 +598,32 @@ const Profile = () => {
|
||||
</div>
|
||||
|
||||
{/* Section 5: Member Directory Settings */}
|
||||
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<BookUser className="h-6 w-6 text-[#ff9e77]" />
|
||||
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<BookUser className="h-6 w-6 text-[var(--orange-light)]" />
|
||||
Member Directory Settings
|
||||
</h2>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Control your visibility and information in the member directory.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 p-4 bg-[#f9f5ff] rounded-lg">
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="show_in_directory"
|
||||
name="show_in_directory"
|
||||
checked={formData.show_in_directory}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="ui-checkbox"
|
||||
/>
|
||||
<Label htmlFor="show_in_directory" className="cursor-pointer text-[#422268] font-medium">
|
||||
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium">
|
||||
Include me in the member directory
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{formData.show_in_directory && (
|
||||
<div className="space-y-6 pl-4 border-l-4 border-[#DDD8EB]">
|
||||
<div className="space-y-6 pl-4 border-l-4 border-[var(--neutral-800)]">
|
||||
<div>
|
||||
<Label htmlFor="directory_email">Directory Email</Label>
|
||||
<Input
|
||||
@@ -489,7 +632,7 @@ const Profile = () => {
|
||||
type="email"
|
||||
value={formData.directory_email}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Optional - email to show in directory"
|
||||
/>
|
||||
</div>
|
||||
@@ -501,7 +644,7 @@ const Profile = () => {
|
||||
name="directory_bio"
|
||||
value={formData.directory_bio}
|
||||
onChange={handleInputChange}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3] min-h-[100px]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
|
||||
placeholder="Tell other members about yourself..."
|
||||
/>
|
||||
</div>
|
||||
@@ -513,7 +656,7 @@ const Profile = () => {
|
||||
name="directory_address"
|
||||
value={formData.directory_address}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Optional - address to show in directory"
|
||||
/>
|
||||
</div>
|
||||
@@ -526,7 +669,7 @@ const Profile = () => {
|
||||
type="tel"
|
||||
value={formData.directory_phone}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Optional - phone to show in directory"
|
||||
/>
|
||||
</div>
|
||||
@@ -539,7 +682,7 @@ const Profile = () => {
|
||||
type="date"
|
||||
value={formData.directory_dob}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -550,7 +693,7 @@ const Profile = () => {
|
||||
name="directory_partner_name"
|
||||
value={formData.directory_partner_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Optional - partner name to show in directory"
|
||||
/>
|
||||
</div>
|
||||
@@ -559,11 +702,11 @@ const Profile = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
|
||||
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-medium shadow-lg disabled:opacity-50"
|
||||
className="bg-brand-purple text-white hover:bg-brand-dark-lavender 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" />
|
||||
@@ -577,6 +720,18 @@ const Profile = () => {
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={setPasswordDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Transaction History Section */}
|
||||
<div className="mt-8">
|
||||
<TransactionHistory
|
||||
subscriptions={transactions.subscriptions}
|
||||
donations={transactions.donations}
|
||||
totalSubscriptionCents={transactions.total_subscription_amount_cents}
|
||||
totalDonationCents={transactions.total_donation_amount_cents}
|
||||
loading={transactionsLoading}
|
||||
isAdmin={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
|
||||
@@ -183,23 +183,23 @@ const Register = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
<Link to="/" className="inline-flex items-center text-[#664fa3] hover:text-[#ff9e77] transition-colors">
|
||||
<Link to="/" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] 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-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Join Our Community
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Fill out the form below to start your membership journey.
|
||||
</p>
|
||||
</div>
|
||||
@@ -245,7 +245,7 @@ const Register = () => {
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
variant="outline"
|
||||
className="rounded-full px-6 py-6 text-lg border-2 border-[#ddd8eb] hover:border-[#664fa3] text-[#422268]"
|
||||
className="rounded-full px-6 py-6 text-lg border-2 border-[var(--neutral-800)] hover:border-brand-purple text-[var(--purple-ink)]"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-5 w-5" />
|
||||
Back
|
||||
@@ -258,7 +258,7 @@ const Register = () => {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform"
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
@@ -267,7 +267,7 @@ const Register = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-backgroundrounded-full px-6 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'}
|
||||
@@ -276,9 +276,9 @@ const Register = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[#664fa3] mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-center text-brand-purple mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-[#ff9e77] hover:underline font-medium">
|
||||
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
|
||||
Login here
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -64,19 +64,19 @@ const ResetPassword = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[#f1eef9] mb-4">
|
||||
<Lock className="h-8 w-8 text-[#664fa3]" />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--lavender-300)] mb-4">
|
||||
<Lock className="h-8 w-8 text-brand-purple " />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Reset Password
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Enter your new password below.
|
||||
</p>
|
||||
</div>
|
||||
@@ -92,7 +92,7 @@ const ResetPassword = () => {
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter new password (min. 6 characters)"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -106,15 +106,15 @@ const ResetPassword = () => {
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Re-enter new password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f1eef9] border-l-4 border-[#664fa3] p-4 rounded-lg">
|
||||
<div className="bg-[var(--lavender-300)] border-l-4 border-brand-purple p-4 rounded-lg">
|
||||
<div className="flex items-start">
|
||||
<AlertCircle className="h-5 w-5 text-[#664fa3] mr-2 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="font-medium text-[#422268] mb-1">Password Requirements:</p>
|
||||
<AlertCircle className="h-5 w-5 text-brand-purple mr-2 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="font-medium text-[var(--purple-ink)] mb-1">Password Requirements:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>At least 6 characters long</li>
|
||||
<li>Both passwords must match</li>
|
||||
@@ -126,15 +126,15 @@ const ResetPassword = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Resetting Password...' : 'Reset Password'}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Remember your password?{' '}
|
||||
<Link to="/login" className="text-[#ff9e77] hover:underline font-medium">
|
||||
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
|
||||
Login here
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import PublicFooter from '../components/PublicFooter';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { ChevronDown, ExternalLink, Phone, Mail, MapPin } from 'lucide-react';
|
||||
|
||||
import { FaFlag, FaHeartbeat, FaUtensils } from "react-icons/fa";
|
||||
const Resources = () => {
|
||||
const [openAccordions, setOpenAccordions] = useState({});
|
||||
|
||||
@@ -23,6 +24,7 @@ const Resources = () => {
|
||||
const categories = [
|
||||
{
|
||||
title: 'General LGBTQ+',
|
||||
icon: <FaFlag />,
|
||||
resources: [
|
||||
{
|
||||
name: 'SPRY (Seniors Preparing for Rainbow Years)',
|
||||
@@ -52,6 +54,7 @@ const Resources = () => {
|
||||
},
|
||||
{
|
||||
title: 'Healthcare',
|
||||
icon: <FaHeartbeat />,
|
||||
resources: [
|
||||
{
|
||||
name: 'LHI (Lesbian Health Initiative)',
|
||||
@@ -67,6 +70,7 @@ const Resources = () => {
|
||||
},
|
||||
{
|
||||
title: 'Food Assistance',
|
||||
icon: <FaUtensils />,
|
||||
resources: [
|
||||
{
|
||||
name: 'Meals on Wheels',
|
||||
@@ -93,18 +97,16 @@ const Resources = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-white via-[#f1eef9] to-[#e8e0f5] px-6 py-16">
|
||||
<main className="bg-gradient-to-b from-white via-[var(--lavender-300)] to-[var(--lavender-100)] px-6 py-16">
|
||||
{/* Header Section */}
|
||||
<section className="max-w-7xl mx-auto mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-[#48286e] text-center mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Resources
|
||||
</h1>
|
||||
<p className="text-xl text-[#48286e] text-center max-w-3xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<h1 className="text-[28px] font-bold text-[var(--purple-deep)] text-center mb-12" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Tap or click on each purple tab below to open and read its contents
|
||||
</p>
|
||||
</h1>
|
||||
|
||||
</section>
|
||||
|
||||
{/* Resources Grid */}
|
||||
@@ -113,7 +115,8 @@ const Resources = () => {
|
||||
{categories.map((category, categoryIndex) => (
|
||||
<div key={categoryIndex} className="space-y-6">
|
||||
{/* Category Title */}
|
||||
<h2 className="text-3xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="flex justify-center text-4xl text-[var(--purple-lilac)]">{category.icon}</div>
|
||||
<h2 className="text-[32px] leading-6 font-bold text-[var(--purple-deep)] text-center mb-8" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
{category.title}
|
||||
</h2>
|
||||
|
||||
@@ -127,27 +130,27 @@ const Resources = () => {
|
||||
{/* Accordion Button */}
|
||||
<button
|
||||
onClick={() => toggleAccordion(categoryIndex, resourceIndex)}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#5a4290] text-white px-6 py-4 rounded-full flex items-center justify-between transition-all shadow-lg hover:shadow-xl"
|
||||
className={`w-full bg-gradient-to-tr from-[var(--purple-deep)] to-[var(--purple-lavender)] hover:bg-[var(--purple-soft)] text-white px-6 py-4 rounded-3xl flex items-center justify-between transition-all ${isExpanded ? 'rounded-b-none rounded-t-3xl' : ''}`
|
||||
|
||||
}
|
||||
>
|
||||
<span className="text-lg font-semibold text-left" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{resource.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-6 w-6 flex-shrink-0 ml-3 transition-transform duration-300 ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
className={`h-6 w-6 flex-shrink-0 ml-3 transition-transform duration-300
|
||||
${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Accordion Content */}
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
isExpanded ? 'max-h-[1000px] opacity-100 mt-3' : 'max-h-0 opacity-0'
|
||||
className={`transition-all duration-300 ease-in-out ${isExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-6 bg-background rounded-b-2xl rounded-t-none border-none ">
|
||||
{/* Description */}
|
||||
<p className="text-[#48286e] mb-4 leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] mb-4 leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{resource.description}
|
||||
</p>
|
||||
|
||||
@@ -155,7 +158,7 @@ const Resources = () => {
|
||||
<div className="space-y-3">
|
||||
{/* Location */}
|
||||
{resource.location && (
|
||||
<div className="flex items-start gap-2 text-[#664fa3]">
|
||||
<div className="flex items-start gap-2 text-[var(--purple-lavender)]">
|
||||
<MapPin className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{resource.location}
|
||||
@@ -165,17 +168,17 @@ const Resources = () => {
|
||||
|
||||
{/* Contact */}
|
||||
{resource.contact && (
|
||||
<div className="text-[#664fa3]">
|
||||
<div className="text-[var(--purple-lavender)]">
|
||||
<p className="text-sm font-medium mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Contact: {resource.contact}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 ml-0">
|
||||
{resource.phone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4" />
|
||||
<Phone className="size-4" />
|
||||
<a
|
||||
href={`tel:${resource.phone.replace(/[^0-9]/g, '')}`}
|
||||
className="text-sm hover:text-[#48286e] transition-colors"
|
||||
className="text-sm hover:text-[var(--purple-deep)] transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{resource.phone}
|
||||
@@ -187,7 +190,7 @@ const Resources = () => {
|
||||
<Mail className="h-4 w-4" />
|
||||
<a
|
||||
href={`mailto:${resource.email}`}
|
||||
className="text-sm hover:text-[#48286e] transition-colors"
|
||||
className="text-sm hover:text-[var(--purple-deep)] transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{resource.email}
|
||||
@@ -204,7 +207,7 @@ const Resources = () => {
|
||||
href={resource.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-[#ff9e77] hover:text-[#e88a63] font-medium transition-colors mt-2"
|
||||
className="inline-flex items-center gap-2 text-[var(--orange-light)] hover:text-[var(--orange-peach)] font-medium transition-colors mt-2"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Visit Website
|
||||
@@ -225,7 +228,7 @@ const Resources = () => {
|
||||
|
||||
{/* Additional Help Section */}
|
||||
<section className="max-w-4xl mx-auto mt-16">
|
||||
<Card className="p-8 bg-gradient-to-r from-[#664fa3] to-[#48286e] rounded-2xl shadow-xl text-center">
|
||||
<Card className="p-8 bg-gradient-to-r from-[var(--purple-lavender)] to-[var(--purple-deep)] rounded-2xl shadow-xl text-center">
|
||||
<h3 className="text-2xl font-bold text-white mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Need Additional Support?
|
||||
</h3>
|
||||
@@ -234,7 +237,7 @@ const Resources = () => {
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@loaf.org"
|
||||
className="inline-block bg-white text-[#48286e] px-8 py-3 rounded-full font-semibold hover:bg-[#f1eef9] transition-colors shadow-lg"
|
||||
className="inline-block bg-background text-[var(--purple-deep)] px-8 py-3 rounded-full font-semibold hover:bg-[var(--lavender-300)] transition-colors shadow-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Contact Us
|
||||
|
||||
605
src/pages/TermsOfService.js
Normal file
605
src/pages/TermsOfService.js
Normal file
@@ -0,0 +1,605 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
|
||||
|
||||
export default function TermsOfService() {
|
||||
return (
|
||||
<>
|
||||
<PublicNavbar />
|
||||
<main className="bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] text-[var(--purple-deep)]">
|
||||
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||
{/* Title */}
|
||||
<header className="border-b pb-6">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight ">
|
||||
Terms of Service
|
||||
</h1>
|
||||
<p className="mt-3 text-sm">
|
||||
<span className="font-medium">Last updated March 23, 2025</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="prose text-[var(--purple-deep)] max-w-none prose-h2:mt-10 prose-h2:scroll-mt-24 prose-h3:mt-6">
|
||||
{/* AGREEMENT */}
|
||||
<section aria-labelledby="agreement" className="mt-8">
|
||||
<h2 id="agreement" className="text-xl sm:text-2xl text-[var(--purple-deep)] font-bold ">
|
||||
AGREEMENT TO OUR LEGAL TERMS
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
We are LOAFers, Inc. ("Company," "we," "us," "our").
|
||||
</p>
|
||||
|
||||
<p>
|
||||
We operate, as well as any other related products and services that refer or link to these legal terms
|
||||
(the "Legal Terms") (collectively, the "Services").
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You can contact us by email at{" "}
|
||||
<a className="" href="mailto:info@loaftx.com">
|
||||
info@loaftx.com
|
||||
</a>{" "}
|
||||
or by mail to PO Box 7207, Houston, TX 77249, United States.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
These Legal Terms constitute a legally binding agreement made between you, whether personally or on behalf
|
||||
of an entity ("you"), and LOAFers, Inc., concerning your access to and use of the Services. You agree that
|
||||
by accessing the Services, you have read, understood, and agreed to be bound by all of these Legal Terms.{" "}
|
||||
<strong>
|
||||
IF YOU DO NOT AGREE WITH ALL OF THESE LEGAL TERMS, THEN YOU ARE EXPRESSLY PROHIBITED FROM USING THE
|
||||
SERVICES AND YOU MUST DISCONTINUE USE IMMEDIATELY.
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Supplemental terms and conditions or documents that may be posted on the Services from time to time are
|
||||
hereby expressly incorporated herein by reference. We reserve the right, in our sole discretion, to make
|
||||
changes or modifications to these Legal Terms at any time and for any reason. We will alert you about any
|
||||
changes by updating the "Last updated" date of these Legal Terms, and you waive any right to receive
|
||||
specific notice of each such change. It is your responsibility to periodically review these Legal Terms to
|
||||
stay informed of updates. You will be subject to, and will be deemed to have been made aware of and to have
|
||||
accepted, the changes in any revised Legal Terms by your continued use of the Services after the date such
|
||||
revised Legal Terms are posted. We recommend that you print a copy of these Legal Terms for your records.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* TABLE OF CONTENTS */}
|
||||
<section aria-labelledby="toc" className="text-[var(--purple-deep)]">
|
||||
<h2 id="toc" className="text-lg sm:text-xl font-bold text-[var(--purple-deep)] m-0">
|
||||
TABLE OF CONTENTS
|
||||
</h2>
|
||||
|
||||
<ol className="mt-4 list-decimal no-prose text-[var(--purple-deep)] pl-5 space-y-1">
|
||||
<li><a className="text-[var(--purple-deep)]" href="#our-services">OUR SERVICES</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#ipr">INTELLECTUAL PROPERTY RIGHTS</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#user-representations">USER REPRESENTATIONS</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#prohibited-activities">PROHIBITED ACTIVITIES</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#ugc">USER GENERATED CONTRIBUTIONS</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#contribution-license">CONTRIBUTION LICENSE</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#services-management">SERVICES MANAGEMENT</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#term-termination">TERM AND TERMINATION</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#modifications">MODIFICATIONS AND INTERRUPTIONS</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#governing-law">GOVERNING LAW</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#dispute-resolution">DISPUTE RESOLUTION</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#corrections">CORRECTIONS</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#disclaimer">DISCLAIMER</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#limitations-liability">LIMITATIONS OF LIABILITY</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#indemnification">INDEMNIFICATION</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#user-data">USER DATA</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#electronic-comms">ELECTRONIC COMMUNICATIONS, TRANSACTIONS, AND SIGNATURES</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#miscellaneous">MISCELLANEOUS</a></li>
|
||||
<li><a className="text-[var(--purple-deep)]" href="#contact-us">CONTACT US</a></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* 1. OUR SERVICES */}
|
||||
<section id="our-services" className="scroll-mt-24">
|
||||
<h2 className="text-xl text-[var(--purple-deep)] sm:text-2xl font-bold ">1. OUR SERVICES</h2>
|
||||
<p>
|
||||
The information provided when using the Services is not intended for distribution to or use by any person
|
||||
or entity in any jurisdiction or country where such distribution or use would be contrary to law or
|
||||
regulation or which would subject us to any registration requirement within such jurisdiction or country.
|
||||
Accordingly, those persons who choose to access the Services from other locations do so on their own
|
||||
initiative and are solely responsible for compliance with local laws, if and to the extent local laws are
|
||||
applicable.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 2. INTELLECTUAL PROPERTY RIGHTS */}
|
||||
<section id="ipr" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)] ">2. INTELLECTUAL PROPERTY RIGHTS</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold ">Our intellectual property</h3>
|
||||
<p>
|
||||
We are the owner or the licensee of all intellectual property rights in our Services, including all source
|
||||
code, databases, functionality, software, website designs, audio, video, text, photographs, and graphics
|
||||
in the Services (collectively, the "Content"), as well as the trademarks, service marks, and logos
|
||||
contained therein (the "Marks"). Our Content and Marks are protected by copyright and trademark laws (and
|
||||
various other intellectual property rights and unfair competition laws) and treaties around the world. The
|
||||
Content and Marks are provided in or through the Services "AS IS" for your personal, non-commercial use or
|
||||
internal business purpose only.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-deep)]">Your use of our Services</h3>
|
||||
<p>
|
||||
Subject to your compliance with these Legal Terms, including the "PROHIBITED ACTIVITIES" section below, we
|
||||
grant you a non-exclusive, non-transferable, revocable license to:
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>access the Services; and</li>
|
||||
<li>
|
||||
download or print a copy of any portion of the Content to which you have properly gained access, solely
|
||||
for your personal, non-commercial use or internal business purpose.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
Except as set out in this section or elsewhere in our Legal Terms, no part of the Services and no Content
|
||||
or Marks may be copied, reproduced, aggregated, republished, uploaded, posted, publicly displayed, encoded,
|
||||
translated, transmitted, distributed, sold, licensed, or otherwise exploited for any commercial purpose
|
||||
whatsoever, without our express prior written permission. If you wish to make any use of the Services,
|
||||
Content, or Marks other than as set out in this section or elsewhere in our Legal Terms, please address
|
||||
your request to:{" "}
|
||||
<a className="" href="mailto:loafhoustontx@gmail.com">
|
||||
loafhoustontx@gmail.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If we ever grant you the permission to post, reproduce, or publicly display any part of our Services or
|
||||
Content, you must identify us as the owners or licensors of the Services, Content, or Marks and ensure that
|
||||
any copyright or proprietary notice appears or is visible on posting, reproducing, or displaying our
|
||||
Content. We reserve all rights not expressly granted to you in and to the Services, Content, and Marks. Any
|
||||
breach of these Intellectual Property Rights will constitute a material breach of our Legal Terms and your
|
||||
right to use our Services will terminate immediately.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-deep)]">Your submissions</h3>
|
||||
<p>
|
||||
Please review this section and the "PROHIBITED ACTIVITIES" section carefully prior to using our Services to
|
||||
understand the (a) rights you give us and (b) obligations you have when you post or upload any content
|
||||
through the Services.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Submissions:</strong> By directly sending us any question, comment, suggestion, idea, feedback, or
|
||||
other information about the Services ("Submissions"), you agree to assign to us all intellectual property
|
||||
rights in such Submission. You agree that we shall own this Submission and be entitled to its unrestricted
|
||||
use and dissemination for any lawful purpose, commercial or otherwise, without acknowledgment or
|
||||
compensation to you.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
You are responsible for what you post or upload: By sending us Submissions through any part of the
|
||||
Services you:
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>
|
||||
confirm that you have read and agree with our "PROHIBITED ACTIVITIES" and will not post, send, publish,
|
||||
upload, or transmit through the Services any Submission that is illegal, harassing, hateful, harmful,
|
||||
defamatory, obscene, bullying, abusive, discriminatory, threatening to any person or group, sexually
|
||||
explicit, false, inaccurate, deceitful, or misleading;
|
||||
</li>
|
||||
<li>to the extent permissible by applicable law, waive any and all moral rights to any such Submission;</li>
|
||||
<li>
|
||||
warrant that any such Submission are original to you or that you have the necessary rights and licenses
|
||||
to submit such Submissions and that you have full authority to grant us the above-mentioned rights in
|
||||
relation to your Submissions; and
|
||||
</li>
|
||||
<li>warrant and represent that your Submissions do not constitute confidential information.</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
You are solely responsible for your Submissions and you expressly agree to reimburse us for any and all
|
||||
losses that we may suffer because of your breach of (a) this section, (b) any third party’s intellectual
|
||||
property rights, or (c) applicable law.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 3. USER REPRESENTATIONS */}
|
||||
<section id="user-representations" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold ">3. USER REPRESENTATIONS</h2>
|
||||
<p>By using the Services, you represent and warrant that:</p>
|
||||
<ol className="list-decimal pl-6 space-y-1">
|
||||
<li>you have the legal capacity and you agree to comply with these Legal Terms;</li>
|
||||
<li>you are not a minor in the jurisdiction in which you reside;</li>
|
||||
<li>you will not access the Services through automated or non-human means, whether through abot, script or otherwise;</li>
|
||||
<li>you will not use the Services for any illegal or unauthorized purpose; and</li>
|
||||
<li>your use of the Services will not violate any applicable law or regulation.</li>
|
||||
</ol>
|
||||
<p>
|
||||
If you provide any information that is untrue, inaccurate, not current, or incomplete, we have the right to
|
||||
suspend or terminate your account and refuse any and all current or future use of the Services (or any
|
||||
portion thereof).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 4. PROHIBITED ACTIVITIES */}
|
||||
<section id="prohibited-activities" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">4. PROHIBITED ACTIVITIES</h2>
|
||||
|
||||
<p>
|
||||
You may not access or use the Services for any purpose other than that for which we make the Services
|
||||
available. The Services may not be used in connection with any commercial endeavors except those that are
|
||||
specifically endorsed or approved by us. As a user of the Services, you agree not to:
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Systematically retrieve data or other content from the Services to create or compile, directly or indirectly, a collection, compilation, database, or directory without written permission from us.</li>
|
||||
<li>Trick, defraud, or mislead us and other users, especially in any attempt to learn sensitive account information such as user passwords.</li>
|
||||
<li>Circumvent, disable, or otherwise interfere with security-related features of the Services, including features that prevent or restrict the use or copying of any Content or enforce limitations on the use of the Services and/or the Content contained therein.</li>
|
||||
<li>Disparage, tarnish, or otherwise harm, in our opinion, us and/or the Services.</li>
|
||||
<li>Use any information obtained from the Services to harass abuse or harm another person.</li>
|
||||
<li>Make improper use of our support services or submit false reports ofabuse or misconduct.</li>
|
||||
<li>Use the Services in a manner inconsistent with any applicable laws or regulations.</li>
|
||||
<li>Engage in unauthorized framing of or linking to the Services.</li>
|
||||
<li>Upload or transmit (or attempt to upload or to transmit) viruses, Trojan horses, or other material, including excessive use of capital letters and spamming (continuous posting of repetitive text), that interferes with any party’s uninterrupted use and enjoyment of the Services or modifies, impairs, disrupts, alters, or interferes with the use, features, functions, operation, or maintenance of the Services.</li>
|
||||
<li>Engage in any automated use of the system, such as using scripts to send comments or messages, or using any data mining, robots, or similar data gathering and extraction tools.</li>
|
||||
<li>Delete the copyright or other proprietary rights notice from any Content.</li>
|
||||
<li>Attempt to impersonate another user or person or use the username of another user.</li>
|
||||
<li>Upload or transmit (or attempt to upload or to transmit) any material that acts as a passive or active information collection or transmission mechanism, including without limitation, clear graphics interchange formats ("gifs"), 1×1 pixels, web bugs, cookies, or other similar devices (sometimes referred to as "spyware" or "passive collection mechanisms" or "pcms").</li>
|
||||
<li>Interfere with, disrupt, or create an undue burden on the Services or the networks or services connected to the Services.</li>
|
||||
<li>Harass, annoy, intimidate, or threaten any of our employees or agents engaged in providing any portion of the Services to you.</li>
|
||||
<li>Attempt to bypass any measures of the Services designed to prevent or restrict access to the Services, or any portion of the Services.</li>
|
||||
<li>Copy or adapt the Services' software, including but not limited to Flash, PHP, HTML, JavaScript, or other code.</li>
|
||||
<li>Except as permitted by applicable law, decipher, decompile, disassemble, or reverse engineer any of the software comprising or in any way making up a part of the Services.</li>
|
||||
<li>Except as may be the result of standard search engine or Internet browser usage, use, launch, develop, or distribute any automated system, including without limitation, any spider, robot, cheat utility, scraper, or offline reader that accesses the Services, or use or launch any unauthorized script or other software.</li>
|
||||
<li>Use a buying agent or purchasing agent to make purchases on the Services.</li>
|
||||
<li>Make any unauthorized use of the Services, including collecting usernames and/or email addresses of users by electronic or other means for the purpose of sending unsolicited email, or creating user accounts by automated means or under false pretenses.</li>
|
||||
<li>Use the Services as part of any effort to compete with us or otherwise use the Services and/or the Content for any revenue-generating endeavor or commercial enterprise.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 5. USER GENERATED CONTRIBUTIONS */}
|
||||
<section id="ugc" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">5. USER GENERATED CONTRIBUTIONS</h2>
|
||||
<p>
|
||||
The Services does not offer users to submit or post content. We may provide you with the opportunity to
|
||||
create, submit, post, display, transmit, perform, publish, distribute, or broadcast content and materials
|
||||
to us or on the Services, including but not limited to text, writings, video, audio, photographs, graphics,
|
||||
comments, suggestions, or personal information or other material (collectively, "Contributions").
|
||||
Contributions may be viewable by other users of the Services and through third-party websites. When you
|
||||
create or make available any Contributions, you thereby represent and warrant that:
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 6. CONTRIBUTION LICENSE */}
|
||||
<section id="contribution-license" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">6. CONTRIBUTION LICENSE</h2>
|
||||
<p>
|
||||
You and Services agree that we may access, store, process, and use any information and personal data that
|
||||
you provide and your choices (including settings).
|
||||
</p>
|
||||
<p>
|
||||
By submitting suggestions or other feedback regarding the Services, you agree that we can use and share
|
||||
such feedback for any purpose without compensation to you.
|
||||
</p>
|
||||
<p>
|
||||
We do not assert any ownership over your Contributions. You retain full ownership of all of your
|
||||
Contributions and any intellectual property rights, or other proprietary rights associated with your
|
||||
Contributions. We are not liable for any statements or representations in your Contributions provided by
|
||||
you in any area on the Services. You are solely responsible for your Contributions to the Services and you
|
||||
expressly agree to exonerate us from any and all responsibility and to refrain from any legal action
|
||||
against us regarding your Contributions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 7. SERVICES MANAGEMENT */}
|
||||
<section id="services-management" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">7. SERVICES MANAGEMENT</h2>
|
||||
<p>
|
||||
We reserve the right, but not the obligation, to: (1) monitor the Services for violations of these Legal
|
||||
Terms; (2) take appropriate legal action against anyone who, in our sole discretion, violates the law or
|
||||
these Legal Terms, including without limitation, reporting such user to law enforcement authorities; (3) in
|
||||
our sole discretion and without limitation, refuse, restrict access to, limit the availability of, or
|
||||
disable (to the extent technologically feasible) any of your Contributions or any portion thereof; (4) in
|
||||
our sole discretion and without limitation, notice, or liability, to remove from the Services or otherwise
|
||||
disable all files and content that are excessive in size or are in any way burdensome to our systems; and
|
||||
(5) otherwise manage the Services in a manner designed to protect our rights and property and to
|
||||
facilitate the proper functioning of the Services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 8. TERM AND TERMINATION */}
|
||||
<section id="term-termination" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">8. TERM AND TERMINATION</h2>
|
||||
<p>
|
||||
These Legal Terms shall remain in full force and effect while you use the Services.{" "}
|
||||
<strong>
|
||||
WITHOUT LIMITING ANY OTHER PROVISION OF THESE LEGAL TERMS, WE RESERVE THE RIGHT TO, IN OUR SOLE
|
||||
DISCRETION AND WITHOUT NOTICE OR LIABILITY, DENY ACCESS TO AND USE OF THE SERVICES (INCLUDING BLOCKING
|
||||
CERTAIN IP ADDRESSES), TO ANY PERSON FOR ANY REASON OR FOR NO REASON, INCLUDING WITHOUT LIMITATION FOR
|
||||
BREACH OF ANY REPRESENTATION, WARRANTY, OR COVENANT CONTAINED IN THESE LEGAL TERMS OR OF ANY APPLICABLE
|
||||
LAW OR REGULATION. WE MAY TERMINATE YOUR USE OR PARTICIPATION IN THE SERVICES OR DELETE ANY CONTENT OR
|
||||
INFORMATION THAT YOU POSTED AT ANY TIME, WITHOUT WARNING, IN OUR SOLE DISCRETION.
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
If we terminate or suspend your account for any reason, you are prohibited from registering and creating a
|
||||
new account under your name, a fake or borrowed name, or the name of any third party, even if you may be
|
||||
acting on behalf of the third party. In addition to terminating or suspending your account, we reserve the
|
||||
right to take appropriate legal action, including without limitation pursuing civil, criminal, and
|
||||
injunctive redress.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 9. MODIFICATIONS AND INTERRUPTIONS */}
|
||||
<section id="modifications" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">9. MODIFICATIONS AND INTERRUPTIONS</h2>
|
||||
<p>
|
||||
We reserve the right to change, modify, or remove the contents of the Services at any time or for any
|
||||
reason at our sole discretion without notice. However, we have no obligation to update any information on
|
||||
our Services. We will not be liable to you or any third party for any modification, price change,
|
||||
suspension, or discontinuance of the Services.
|
||||
</p>
|
||||
<p>
|
||||
We cannot guarantee the Services will be available at all times. We may experience hardware, software, or
|
||||
other problems or need to perform maintenance related to the Services, resulting in interruptions, delays,
|
||||
or errors. We reserve the right to change, revise, update, suspend, discontinue, or otherwise modify the
|
||||
Services at any time or for any reason without notice to you.
|
||||
</p>
|
||||
<p>
|
||||
You agree that we have no liability whatsoever for any loss, damage, or inconvenience caused by your
|
||||
inability to access or use the Services during any downtime or discontinuance of the Services. Nothing in
|
||||
these Legal Terms will be construed to obligate us to maintain and support the Services or to supply any
|
||||
corrections, updates, or releases in connection therewith.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 10. GOVERNING LAW */}
|
||||
<section id="governing-law" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">10. GOVERNING LAW</h2>
|
||||
<p>
|
||||
These Legal Terms shall be governed by and defined following the laws of Texas. LOAFers, Inc. and yourself
|
||||
irrevocably consent that the courts of Houston shall have exclusive jurisdiction to resolve any dispute
|
||||
which may arise in connection with these Legal Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 11. DISPUTE RESOLUTION */}
|
||||
<section id="dispute-resolution" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">11. DISPUTE RESOLUTION</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold ">Informal Negotiations</h3>
|
||||
<p>
|
||||
To expedite resolution and control the cost of any dispute, controversy, or claim related to these Legal
|
||||
Terms (each a "Dispute" and collectively, the "Disputes") brought by either you or us (individually, a
|
||||
"Party" and collectively, the "Parties"), the Parties agree to first attempt to negotiate any Dispute
|
||||
(except those Disputes expressly provided below) informally for at least ___60_____ days before initiating
|
||||
arbitration. Such informal negotiations commence upon written notice from one Party to the other Party.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-deep)]">Binding Arbitration</h3>
|
||||
<p>
|
||||
Any dispute arising out of or in connection with these Legal Terms, including any question regarding its
|
||||
existence, validity, or termination, shall be referred to and finally resolved by the Disputy Resolution
|
||||
Center of Harris County{" "}
|
||||
<a
|
||||
className=""
|
||||
href="https://drc.harriscountytx.gov/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://drc.harriscountytx.gov/
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-deep)]">Restrictions</h3>
|
||||
<p>
|
||||
The Parties agree that any arbitration shall be limited to the Dispute between the Parties individually.
|
||||
To the full extent permitted by law, (a) no arbitration shall be joined with any other proceeding; (b)
|
||||
there is no right or authority for any Dispute to be arbitrated on a class-action basis or to utilize class
|
||||
action procedures; and (c) there is no right or authority for any Dispute to be brought in a purported
|
||||
representative capacity on behalf of the general public or any other persons.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-deep)]">Exceptions to Informal Negotiations and Arbitration</h3>
|
||||
<p>
|
||||
The Parties agree that the following Disputes are not subject to the above provisions concerning informal
|
||||
negotiations binding arbitration: (a) any Disputes seeking to enforce or protect, or concerning the validity
|
||||
of, any of the intellectual property rights of a Party; (b) any Dispute related to, or arising from,
|
||||
allegations of theft, piracy, invasion of privacy, or unauthorized use; and (c) any claim for injunctive
|
||||
relief.
|
||||
</p>
|
||||
<p>
|
||||
If this provision is found to be illegal or unenforceable, then neither Party will elect to arbitrate any
|
||||
Dispute falling within that portion of this provision found to be illegal or unenforceable and such Dispute
|
||||
shall be decided by a court of competent jurisdiction within the courts listed for jurisdiction above, and
|
||||
the Parties agree to submit to the personal jurisdiction of that court.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 12. CORRECTIONS */}
|
||||
<section id="corrections" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">12. CORRECTIONS</h2>
|
||||
<p>
|
||||
There may be information on the Services that contains typographical errors, inaccuracies, or omissions,
|
||||
including descriptions, pricing, availability, and various other information. We reserve the right to
|
||||
correct any errors, inaccuracies, or omissions and to change or update the information on the Services at
|
||||
any time, without prior notice.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 13. DISCLAIMER */}
|
||||
<section id="disclaimer" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">13. DISCLAIMER</h2>
|
||||
<p className="font-semibold">
|
||||
THE SERVICES ARE PROVIDED ON AN AS-IS AND AS-AVAILABLE BASIS. YOU AGREE THAT YOUR USE OF THE SERVICES WILL
|
||||
BE AT YOUR SOLE RISK. TO THE FULLEST EXTENT PERMITTED BY LAW, WE DISCLAIM ALL WARRANTIES, EXPRESS OR
|
||||
IMPLIED, IN CONNECTION WITH THE SERVICES AND YOUR USE THEREOF, INCLUDING, WITHOUT LIMITATION, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
WE MAKE NO WARRANTIES OR REPRESENTATIONS ABOUT THE ACCURACY OR COMPLETENESS OF THE SERVICES' CONTENT OR THE
|
||||
CONTENT OF ANY WEBSITES OR MOBILE APPLICATIONS LINKED TO THE SERVICES AND WE WILL ASSUME NO LIABILITY OR
|
||||
RESPONSIBILITY FOR ANY (1) ERRORS, MISTAKES, OR INACCURACIES OF CONTENT AND MATERIALS, (2) PERSONAL INJURY
|
||||
OR PROPERTY DAMAGE, OF ANY NATURE WHATSOEVER, RESULTING FROM YOUR ACCESS TO AND USE OF THE SERVICES, (3)
|
||||
ANY UNAUTHORIZED ACCESS TO OR USE OF OUR SECURE SERVERS AND/OR ANY AND ALL PERSONAL INFORMATION AND/OR
|
||||
FINANCIAL INFORMATION STORED THEREIN, (4) ANY INTERRUPTION OR CESSATION OF TRANSMISSION TO OR FROM THE
|
||||
SERVICES, (5) ANY BUGS, VIRUSES, TROJAN HORSES, OR THE LIKE WHICH MAY BE TRANSMITTED TO OR THROUGH THE
|
||||
SERVICES BY ANY THIRD PARTY, AND/OR (6) ANY ERRORS OR OMISSIONS IN ANY CONTENT AND MATERIALS OR FOR ANY LOSS
|
||||
OR DAMAGE OF ANY KIND INCURRED AS A RESULT OF THE USE OF ANY CONTENT POSTED, TRANSMITTED, OR OTHERWISE MADE
|
||||
AVAILABLE VIA THE SERVICES.
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
WE DO NOT WARRANT, ENDORSE, GUARANTEE, OR ASSUME RESPONSIBILITY FOR ANY PRODUCT OR SERVICE ADVERTISED OR
|
||||
OFFERED BY A THIRD PARTY THROUGH THE SERVICES, ANY HYPERLINKED WEBSITE, OR ANY WEBSITE OR MOBILE APPLICATION
|
||||
FEATURED IN ANY BANNER OR OTHER ADVERTISING, AND WE WILL NOT BE A PARTY TO OR IN ANY WAY BE RESPONSIBLE FOR
|
||||
MONITORING ANY TRANSACTION BETWEEN YOU AND ANY THIRD-PARTY PROVIDERS OF PRODUCTS OR SERVICES. AS WITH THE
|
||||
PURCHASE OF A PRODUCT OR SERVICE THROUGH ANY MEDIUM OR IN ANY ENVIRONMENT, YOU SHOULD USE YOUR BEST
|
||||
JUDGMENT AND EXERCISE CAUTION WHERE APPROPRIATE.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 14. LIMITATIONS OF LIABILITY */}
|
||||
<section id="limitations-liability" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">14. LIMITATIONS OF LIABILITY</h2>
|
||||
<p className="font-semibold">
|
||||
IN NO EVENT WILL WE OR OUR DIRECTORS, EMPLOYEES, OR AGENTS BE LIABLE TO YOU OR ANY THIRD PARTY FOR ANY
|
||||
DIRECT, INDIRECT, CONSEQUENTIAL, EXEMPLARY, INCIDENTAL, SPECIAL, OR PUNITIVE DAMAGES, INCLUDING LOST
|
||||
PROFIT, LOST REVENUE, LOSS OF DATA, OR OTHER DAMAGES ARISING FROM YOUR USE OF THE SERVICES, EVEN IF WE HAVE
|
||||
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
NOTWITHSTANDING ANYTHING TO THE CONTRARY CONTAINED HEREIN, OUR LIABILITY TO YOU FOR ANY CAUSE WHATSOEVER
|
||||
AND REGARDLESS OF THE FORM OF THE ACTION, WILL AT ALL TIMES BE LIMITED TO THE LESSER OF THE AMOUNT PAID, IF
|
||||
ANY, BY YOU TO US OR .
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
CERTAIN US STATE LAWS AND INTERNATIONAL LAWS DO NOT ALLOW LIMITATIONS ON IMPLIED WARRANTIES OR THE
|
||||
EXCLUSION OR LIMITATION OF CERTAIN DAMAGES. IF THESE LAWS APPLY TO YOU, SOME OR ALL OF THE ABOVE
|
||||
DISCLAIMERS OR LIMITATIONS MAY NOT APPLY TO YOU, AND YOU MAY HAVE ADDITIONAL RIGHTS.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 15. INDEMNIFICATION */}
|
||||
<section id="indemnification" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">15. INDEMNIFICATION</h2>
|
||||
<p>
|
||||
You agree to defend, indemnify, and hold us harmless, including our subsidiaries, affiliates, and all of
|
||||
our respective officers, agents, partners, and employees, from and against any loss, damage, liability,
|
||||
claim, or demand, including reasonable attorneys’ fees and expenses, made by any third party due to or
|
||||
arising out of: (1) use of the Services; (2) breach of these Legal Terms; (3) any breach of your
|
||||
representations and warranties set forth in these Legal Terms; (4) your violation of the rights of a third
|
||||
party, including but not limited to intellectual property rights; or (5) any overt harmful act toward any
|
||||
other user of the Services with whom you connected via the Services.
|
||||
</p>
|
||||
<p>
|
||||
Notwithstanding the foregoing, we reserve the right, at your expense, to assume the exclusive defense and
|
||||
control of any matter for which you are required to indemnify us, and you agree to cooperate, at your
|
||||
expense, with our defense of such claims. We will use reasonable efforts to notify you of any such claim,
|
||||
action, or proceeding which is subject to this indemnification upon becoming aware of it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 16. USER DATA */}
|
||||
<section id="user-data" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">16. USER DATA</h2>
|
||||
<p>
|
||||
We will maintain certain data that you transmit to the Services for the purpose of managing the performance
|
||||
of the Services, as well as data relating to your use of the Services. Although we perform regular routine
|
||||
backups of data, you are solely responsible for all data that you transmit or that relates to any activity
|
||||
you have undertaken using the Services. You agree that we shall have no liability to you for any loss or
|
||||
corruption of any such data, and you hereby waive any right of action against us arising from any such loss
|
||||
or corruption of such data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 17. ELECTRONIC COMMUNICATIONS */}
|
||||
<section id="electronic-comms" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">
|
||||
17. ELECTRONIC COMMUNICATIONS, TRANSACTIONS, AND SIGNATURES
|
||||
</h2>
|
||||
<p>
|
||||
Visiting the Services, sending us emails, and completing online forms constitute electronic communications.
|
||||
You consent to receive electronic communications, and you agree that all agreements, notices, disclosures,
|
||||
and other communications we provide to you electronically, via email and on the Services, satisfy any legal
|
||||
requirement that such communication be in writing.
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
YOU HEREBY AGREE TO THE USE OF ELECTRONIC SIGNATURES, CONTRACTS, ORDERS, AND OTHER RECORDS, AND TO
|
||||
ELECTRONIC DELIVERY OF NOTICES, POLICIES, AND RECORDS OF TRANSACTIONS INITIATED OR COMPLETED BY US OR VIA
|
||||
THE SERVICES.
|
||||
</p>
|
||||
<p>
|
||||
You hereby waive any rights or requirements under any statutes, regulations, rules, ordinances, or other
|
||||
laws in any jurisdiction which require an original signature or delivery or retention of non-electronic
|
||||
records, or to payments or the granting of credits by any means other than electronic means.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 18. MISCELLANEOUS */}
|
||||
<section id="miscellaneous" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">18. MISCELLANEOUS</h2>
|
||||
<p>
|
||||
These Legal Terms and any policies or operating rules posted by us on the Services or in respect to the
|
||||
Services constitute the entire agreement and understanding between you and us. Our failure to exercise or
|
||||
enforce any right or provision of these Legal Terms shall not operate as a waiver of such right or
|
||||
provision.
|
||||
</p>
|
||||
<p>
|
||||
These Legal Terms operate to the fullest extent permissible by law. We may assign any or all of our rights
|
||||
and obligations to others at any time. We shall not be responsible or liable for any loss, damage, delay,
|
||||
or failure to act caused by any cause beyond our reasonable control.
|
||||
</p>
|
||||
<p>
|
||||
If any provision or part of a provision of these Legal Terms is determined to be unlawful, void, or
|
||||
unenforceable, that provision or part of the provision is deemed severable from these Legal Terms and does
|
||||
not affect the validity and enforceability of any remaining provisions.
|
||||
</p>
|
||||
<p>
|
||||
There is no joint venture, partnership, employment or agency relationship created between you and us as a
|
||||
result of these Legal Terms or use of the Services. You agree that these Legal Terms will not be construed
|
||||
against us by virtue of having drafted them. You hereby waive any and all defenses you may have based on
|
||||
the electronic form of these Legal Terms and the lack of signing by the parties hereto to execute these
|
||||
Legal Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 19. CONTACT US */}
|
||||
<section id="contact-us" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">19. CONTACT US</h2>
|
||||
<p>
|
||||
In order to resolve a complaint regarding the Services or to receive further information regarding use of
|
||||
the Services, please contact us at:
|
||||
</p>
|
||||
|
||||
<div className="not-prose mt-4 ">
|
||||
<p className="font-semibold mb-2">LOAFers, Inc.</p>
|
||||
<p className="">PO Box 7207</p>
|
||||
<p className="">Houston, TX 77249</p>
|
||||
<p className="">United States</p>
|
||||
<p className="mt-3">
|
||||
<a className="" href="mailto:info@loaftx.org">
|
||||
info@loaftx.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/* Back to Home Link */}
|
||||
<div className="mt-8 text-center">
|
||||
<Link to="/" className="text-[var(--purple-lavender)] hover:text-[var(--purple-ink)] font-semibold transition-colors inline-flex items-center gap-2"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span>←</span> Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<PublicFooter />
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -45,18 +45,18 @@ const VerifyEmail = () => {
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-2xl mx-auto px-6 py-20">
|
||||
<Card className="p-6 sm:p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg text-center">
|
||||
<Card className="p-6 sm:p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg text-center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<Loader2 className="h-20 w-20 text-[#664fa3] mx-auto mb-6 animate-spin" />
|
||||
<h1 className="text-3xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Loader2 className="h-20 w-20 text-brand-purple mx-auto mb-6 animate-spin" />
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Verifying Your Email...
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Please wait while we verify your email address.
|
||||
</p>
|
||||
</>
|
||||
@@ -64,19 +64,19 @@ const VerifyEmail = () => {
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="h-20 w-20 text-[#81B29A] mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CheckCircle className="h-20 w-20 text-[var(--green-light)] mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Email Verified Successfully!
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{message}
|
||||
</p>
|
||||
<p className="text-base text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-base text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-12 py-6 text-lg font-medium shadow-lg"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-12 py-6 text-lg font-medium shadow-lg"
|
||||
data-testid="login-redirect-button"
|
||||
>
|
||||
Go to Login
|
||||
@@ -88,15 +88,15 @@ const VerifyEmail = () => {
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="h-20 w-20 text-red-500 mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Verification Failed
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{message}
|
||||
</p>
|
||||
<Link to="/">
|
||||
<Button
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-12 py-6 text-lg font-medium shadow-lg"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-12 py-6 text-lg font-medium shadow-lg"
|
||||
data-testid="home-redirect-button"
|
||||
>
|
||||
Go to Home
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
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-orange-100 text-orange-700' },
|
||||
pending_approval: { label: 'Pending', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 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 text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Approval Queue
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and approve pending membership applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Pending</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Awaiting Email</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pending_email').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Approval</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pending_approval').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Approved</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pre_approved').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'payment_pending').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Filter Card */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] 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-[#664fa3]" />
|
||||
<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-[#ddd8eb] focus:border-[#664fa3]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]">
|
||||
<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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading pending applications...</p>
|
||||
</div>
|
||||
) : filteredUsers.length > 0 ? (
|
||||
<>
|
||||
<Card className="bg-white rounded-2xl border border-[#ddd8eb] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-[#DDD8EB]/20"
|
||||
onClick={() => handleSort('first_name')}
|
||||
>
|
||||
Name {renderSortIcon('first_name')}
|
||||
</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Phone</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-[#DDD8EB]/20"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
Status {renderSortIcon('status')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-[#DDD8EB]/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-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
{actionLoading === user.id ? 'Approving...' : 'Bypass & Approve'}
|
||||
</Button>
|
||||
) : user.status === 'payment_pending' ? (
|
||||
<Button
|
||||
onClick={() => handleActivatePayment(user)}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
<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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Pending Approvals
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminBylaws = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [bylaws, setBylaws] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -44,7 +46,7 @@ const AdminBylaws = () => {
|
||||
version: '',
|
||||
effective_date: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive',
|
||||
document_type: 'link',
|
||||
is_current: false
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -71,9 +73,10 @@ const AdminBylaws = () => {
|
||||
version: '',
|
||||
effective_date: new Date().toISOString().split('T')[0],
|
||||
document_url: '',
|
||||
document_type: 'google_drive',
|
||||
document_type: 'link',
|
||||
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
|
||||
});
|
||||
setUploadedFile(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -166,7 +169,7 @@ const AdminBylaws = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]">Loading bylaws...</p>
|
||||
<p className="text-brand-purple ">Loading bylaws...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -176,40 +179,42 @@ const AdminBylaws = () => {
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Bylaws Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage LOAF governing bylaws and version history
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission('bylaws.create') && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
className="btn-lavender flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Version
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Bylaws */}
|
||||
{currentBylaws ? (
|
||||
<Card className="p-6 border-2 border-[#664fa3]">
|
||||
<Card className="p-6 border-2 border-brand-purple ">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-3 rounded-xl">
|
||||
<Scale className="h-6 w-6 text-white" />
|
||||
<div className="bg-light-lavender p-3 rounded-xl">
|
||||
<Scale className="h-6 w-6 " />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-[#422268]">
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)]">
|
||||
{currentBylaws.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className="bg-[#81B29A] text-white">
|
||||
<Badge variant={'green'} className="">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Current Version
|
||||
</Badge>
|
||||
<span className="text-[#664fa3] text-sm">
|
||||
<span className="text-brand-purple text-sm">
|
||||
Version {currentBylaws.version}
|
||||
</span>
|
||||
</div>
|
||||
@@ -217,53 +222,59 @@ const AdminBylaws = () => {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(currentBylaws.document_url, '_blank')}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
className="border-brand-purple text-brand-purple "
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
{hasPermission('bylaws.edit') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(currentBylaws)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
className="border-brand-purple text-brand-purple "
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('bylaws.delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="outline-destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(currentBylaws)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
className="border-red-500 text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
||||
<div className="flex items-center gap-4 text-sm text-brand-purple ">
|
||||
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
|
||||
<span>•</span>
|
||||
<span>Document Type: <strong>{currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()}</strong></span>
|
||||
<span>Document Type: <strong>{currentBylaws.document_type === 'upload' ? 'PDF Upload' : 'Link'}</strong></span>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-12 text-center">
|
||||
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Scale className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<p className="text-brand-purple text-lg mb-4">No current bylaws set</p>
|
||||
{hasPermission('bylaws.create') && (
|
||||
<Button onClick={handleCreate} className="bg-brand-purple text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Bylaws
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Historical Versions */}
|
||||
{historicalBylaws.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[#422268] mb-4">
|
||||
<h2 className="text-xl font-semibold text-[var(--purple-ink)] mb-4">
|
||||
Version History ({historicalBylaws.length})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
@@ -271,10 +282,10 @@ const AdminBylaws = () => {
|
||||
<Card key={bylawsDoc.id} className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-1">
|
||||
{bylawsDoc.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-sm text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-sm text-brand-purple ">
|
||||
<span>Version {bylawsDoc.version}</span>
|
||||
<span>•</span>
|
||||
<span>Effective {formatDate(bylawsDoc.effective_date)}</span>
|
||||
@@ -285,26 +296,30 @@ const AdminBylaws = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(bylawsDoc.document_url, '_blank')}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
className="border-brand-purple text-brand-purple "
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
{hasPermission('bylaws.edit') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(bylawsDoc)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
className="border-brand-purple text-brand-purple "
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('bylaws.delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(bylawsDoc)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
className="btn-outline-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -363,14 +378,16 @@ const AdminBylaws = () => {
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||
setUploadedFile(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -387,10 +404,15 @@ const AdminBylaws = () => {
|
||||
required={!selectedBylaws}
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
{selectedBylaws && !uploadedFile && (
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Current file will be kept if no new file is selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -399,12 +421,11 @@ const AdminBylaws = () => {
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
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
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -434,7 +455,7 @@ const AdminBylaws = () => {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#664fa3] text-white"
|
||||
className="bg-brand-purple text-white"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : selectedBylaws ? 'Update' : 'Create'}
|
||||
@@ -461,9 +482,9 @@ const AdminBylaws = () => {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
className="btn-outline-destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
@@ -4,14 +4,18 @@ 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';
|
||||
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle, Globe, CircleMinus } from 'lucide-react';
|
||||
import { StatCard } from '../../components/StatCard';
|
||||
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const [stats, setStats] = useState({
|
||||
totalMembers: 0,
|
||||
pendingApprovals: 0,
|
||||
activeMembers: 0
|
||||
pendingValidations: 0,
|
||||
activeMembers: 0,
|
||||
inactiveMembers: 0
|
||||
});
|
||||
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,11 +29,28 @@ const AdminDashboard = () => {
|
||||
|
||||
setStats({
|
||||
totalMembers: users.filter(u => u.role === 'member').length,
|
||||
pendingApprovals: users.filter(u =>
|
||||
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(u.status)
|
||||
pendingValidations: users.filter(u =>
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
|
||||
).length,
|
||||
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length
|
||||
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length,
|
||||
inactiveMembers: users.filter(u => u.status === 'inactive' && u.role === 'member').length
|
||||
});
|
||||
|
||||
// Find users who have received 3+ reminders (may need personal outreach)
|
||||
const needingAttention = users.filter(u => {
|
||||
const emailReminders = u.email_verification_reminders_sent || 0;
|
||||
const eventReminders = u.event_attendance_reminders_sent || 0;
|
||||
const paymentReminders = u.payment_reminders_sent || 0;
|
||||
const totalReminders = emailReminders + eventReminders + paymentReminders;
|
||||
return totalReminders >= 3;
|
||||
}).map(u => ({
|
||||
...u,
|
||||
totalReminders: (u.email_verification_reminders_sent || 0) +
|
||||
(u.event_attendance_reminders_sent || 0) +
|
||||
(u.payment_reminders_sent || 0)
|
||||
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
|
||||
|
||||
setUsersNeedingAttention(needingAttention);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
} finally {
|
||||
@@ -39,92 +60,183 @@ const AdminDashboard = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage users, events, and membership applications.
|
||||
</p>
|
||||
</div>
|
||||
<Link to={'/'}>
|
||||
<Button
|
||||
className="btn-lavender"
|
||||
>
|
||||
<Globe />
|
||||
View Public Site
|
||||
</Button>
|
||||
</Link>
|
||||
</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-[#ddd8eb]" data-testid="stat-total-users">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<Users className="h-6 w-6 text-[#664fa3]" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.totalMembers}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-approvals">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-orange-100 p-3 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-orange-600" />
|
||||
<div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
|
||||
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
|
||||
Quick Overview
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.pendingApprovals}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Approvals</p>
|
||||
</Card>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 ">
|
||||
<StatCard
|
||||
title="Total Members"
|
||||
value={loading ? '-' : stats.totalMembers}
|
||||
icon={Users}
|
||||
iconBgClass="text-brand-purple"
|
||||
dataTestId="stat-total-users"
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Validations"
|
||||
value={loading ? '-' : stats.pendingValidations}
|
||||
icon={Clock}
|
||||
iconBgClass="text-brand-light-orange"
|
||||
dataTestId="stat-total-users"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Members"
|
||||
value={loading ? '-' : stats.activeMembers}
|
||||
icon={CheckCircle}
|
||||
iconBgClass="text-[var(--green-light)]"
|
||||
dataTestId="stat-total-users"
|
||||
/>
|
||||
<StatCard
|
||||
title="Inactive Members"
|
||||
value={loading ? '-' : stats.inactiveMembers}
|
||||
icon={CircleMinus}
|
||||
iconBgClass="text-brand-pink"
|
||||
dataTestId="stat-total-users"
|
||||
/>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" 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 text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.activeMembers}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
|
||||
<Users className="h-12 w-12 text-[#664fa3] mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Manage Users
|
||||
<Link to="/admin/members">
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
|
||||
<Users className="h-12 w-12 text-brand-purple mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Manage Members
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage all registered users and their membership status.
|
||||
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage paying members and their subscription status.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
className="btn-lavender mt-4"
|
||||
data-testid="manage-users-button"
|
||||
>
|
||||
Go to Users
|
||||
Go to Members
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link to="/admin/approvals">
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-approvals">
|
||||
<Link to="/admin/validations">
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-validations">
|
||||
<Clock className="h-12 w-12 text-orange-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Approval Queue
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Validation Queue
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and approve pending membership applications.
|
||||
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and validate pending membership applications.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-approvals-button"
|
||||
className="mt-4 btn-lavender"
|
||||
data-testid="manage-validations-button"
|
||||
>
|
||||
View Approvals
|
||||
View Validations
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Users Needing Attention Widget */}
|
||||
{usersNeedingAttention.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<Card className="p-8 bg-background rounded-2xl border-2 border-[var(--orange-light)] shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="bg-[var(--orange-light)]/20 p-3 rounded-lg">
|
||||
<AlertCircle className="h-6 w-6 text-[var(--orange-light)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Needing Personal Outreach
|
||||
</h3>
|
||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
These members have received multiple reminder emails. Consider calling them directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{usersNeedingAttention.map(user => (
|
||||
<Link key={user.id} to={`/admin/users/${user.id}`}>
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)] hover:border-[var(--orange-light)] hover:shadow-md transition-all cursor-pointer">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h4>
|
||||
<Badge className="bg-[var(--orange-light)] text-white px-3 py-1 rounded-full text-xs">
|
||||
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone || 'N/A'}</p>
|
||||
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
|
||||
{user.email_verification_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Mail className="inline h-3 w-3 mr-1" />
|
||||
{user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
{user.event_attendance_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Calendar className="inline h-3 w-3 mr-1" />
|
||||
{user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
{user.payment_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Clock className="inline h-3 w-3 mr-1" />
|
||||
{user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full text-sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `tel:${user.phone}`;
|
||||
}}
|
||||
>
|
||||
Call Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-[var(--neutral-800)]/20 rounded-lg border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email.
|
||||
A friendly phone call can help them complete the registration process and feel more welcomed to the community.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
638
src/pages/admin/AdminDonations.js
Normal file
638
src/pages/admin/AdminDonations.js
Normal file
@@ -0,0 +1,638 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../components/ui/dropdown-menu';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import api from '../../utils/api';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
DollarSign,
|
||||
Heart,
|
||||
Users,
|
||||
Globe,
|
||||
Search,
|
||||
Loader2,
|
||||
Download,
|
||||
FileDown,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminDonations = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [donations, setDonations] = useState([]);
|
||||
const [filteredDonations, setFilteredDonations] = useState([]);
|
||||
const [stats, setStats] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [expandedRows, setExpandedRows] = useState(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterDonations();
|
||||
}, [searchQuery, typeFilter, statusFilter, startDate, endDate, donations]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [donationsResponse, statsResponse] = await Promise.all([
|
||||
api.get('/admin/donations'),
|
||||
api.get('/admin/donations/stats')
|
||||
]);
|
||||
|
||||
setDonations(donationsResponse.data);
|
||||
setStats(statsResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch donation data:', error);
|
||||
toast.error('Failed to load donation data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterDonations = () => {
|
||||
let filtered = [...donations];
|
||||
|
||||
// Search filter (donor name or email)
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(donation =>
|
||||
donation.donor_name?.toLowerCase().includes(query) ||
|
||||
donation.donor_email?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(donation => donation.donation_type === typeFilter);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(donation => donation.status === statusFilter);
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (startDate) {
|
||||
filtered = filtered.filter(donation =>
|
||||
new Date(donation.created_at) >= new Date(startDate)
|
||||
);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
filtered = filtered.filter(donation =>
|
||||
new Date(donation.created_at) <= new Date(endDate)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredDonations(filtered);
|
||||
};
|
||||
|
||||
const handleExport = async (exportType) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const params = exportType === 'current' ? {
|
||||
donation_type: typeFilter !== 'all' ? typeFilter : undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
search: searchQuery || undefined
|
||||
} : {};
|
||||
|
||||
const response = await api.get('/admin/donations/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `donations_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Donations exported successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to export donations:', error);
|
||||
toast.error('Failed to export donations');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (cents) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRowExpansion = (donationId) => {
|
||||
setExpandedRows((prev) => {
|
||||
const newExpanded = new Set(prev);
|
||||
if (newExpanded.has(donationId)) {
|
||||
newExpanded.delete(donationId);
|
||||
} else {
|
||||
newExpanded.add(donationId);
|
||||
}
|
||||
return newExpanded;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text, label) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status) => {
|
||||
const variants = {
|
||||
completed: 'default',
|
||||
pending: 'secondary',
|
||||
failed: 'destructive'
|
||||
};
|
||||
return variants[status] || 'outline';
|
||||
};
|
||||
|
||||
const getTypeBadgeColor = (type) => {
|
||||
return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-brand-purple ';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-brand-purple " />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Donation Management
|
||||
</h1>
|
||||
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Track and manage all donations from members and the public
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid md:grid-cols-4 gap-6">
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Total Donations
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{stats.total_donations || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
|
||||
<Heart className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Member Donations
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-[var(--green-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{stats.member_donations || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--green-light)]/10 rounded-full">
|
||||
<Users className="h-6 w-6 text-[var(--green-light)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Public Donations
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-brand-purple mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{stats.public_donations || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
|
||||
<Globe className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Total Amount
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{stats.total_amount || '$0.00'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
|
||||
<DollarSign className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Actions */}
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="space-y-4">
|
||||
{/* Search and Export Row */}
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||
<Input
|
||||
placeholder="Search by donor name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 rounded-full border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
{hasPermission('donations.export') && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={exporting}
|
||||
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-soft)] rounded-full px-6 py-3 flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-background rounded-xl border-2 border-[var(--neutral-800)] shadow-lg">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('all')}
|
||||
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-brand-purple " />
|
||||
<span className="text-[var(--purple-ink)]">Export All Donations</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('current')}
|
||||
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-brand-purple " />
|
||||
<span className="text-[var(--purple-ink)]">Export Current View</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="rounded-full border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="member">Member Donations</SelectItem>
|
||||
<SelectItem value="public">Public Donations</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="rounded-full border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
||||
placeholder="Start Date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
||||
placeholder="End Date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Summary */}
|
||||
{(searchQuery || typeFilter !== 'all' || statusFilter !== 'all' || startDate || endDate) && (
|
||||
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Showing {filteredDonations.length} of {donations.length} donations
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Donations Table */}
|
||||
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[var(--lavender-300)] border-b-2 border-[var(--neutral-800)]">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Donor
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Method
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Details
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--neutral-800)]">
|
||||
{filteredDonations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="px-6 py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Heart className="h-12 w-12 text-[var(--neutral-800)]" />
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{donations.length === 0 ? 'No donations yet' : 'No donations match your filters'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredDonations.map((donation) => {
|
||||
const isExpanded = expandedRows.has(donation.id);
|
||||
return (
|
||||
<React.Fragment key={donation.id}>
|
||||
<tr className="hover:bg-[var(--lavender-400)] transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{donation.donor_name || 'Anonymous'}
|
||||
</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{donation.donor_email || 'No email'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge
|
||||
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none rounded-full px-3 py-1`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{donation.donation_type === 'member' ? 'Member' : 'Public'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{donation.amount}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant={getStatusBadgeVariant(donation.status)} className="rounded-full">
|
||||
{donation.status.charAt(0).toUpperCase() + donation.status.slice(1)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-brand-purple ">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{formatDate(donation.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{donation.payment_method || 'N/A'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleRowExpansion(donation.id)}
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Expandable Details Row */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-[var(--lavender-400)]/30">
|
||||
<td colSpan="7" className="px-6 py-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Transaction Details
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Payment Information */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Payment Information
|
||||
</h5>
|
||||
<div className="space-y-2 text-sm">
|
||||
{donation.payment_completed_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Payment Date:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium">{formatDate(donation.payment_completed_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
{donation.payment_method && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Payment Method:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium capitalize">{donation.payment_method}</span>
|
||||
</div>
|
||||
)}
|
||||
{donation.card_brand && donation.card_last4 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Card:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium">{donation.card_brand} ****{donation.card_last4}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe Transaction IDs */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Info className="h-4 w-4" />
|
||||
Stripe Transaction IDs
|
||||
</h5>
|
||||
<div className="space-y-2 text-sm">
|
||||
{donation.stripe_payment_intent_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Payment Intent:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{donation.stripe_payment_intent_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(donation.stripe_payment_intent_id, 'Payment Intent ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{donation.stripe_charge_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Charge ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{donation.stripe_charge_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(donation.stripe_charge_id, 'Charge ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{donation.stripe_customer_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Customer ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{donation.stripe_customer_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(donation.stripe_customer_id, 'Customer ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{donation.stripe_receipt_url && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Receipt:</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(donation.stripe_receipt_url, '_blank')}
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View Receipt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* This Month Summary */}
|
||||
{stats.this_month_count > 0 && (
|
||||
<Card className="p-6 bg-gradient-to-r from-[var(--lavender-400)] to-[var(--lavender-300)] rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
This Month's Donations
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{stats.this_month_count} donations • {stats.this_month_amount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background rounded-full shadow-sm">
|
||||
<Heart className="h-8 w-8 text-[var(--orange-light)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDonations;
|
||||
543
src/pages/admin/AdminEventAttendance.js
Normal file
543
src/pages/admin/AdminEventAttendance.js
Normal file
@@ -0,0 +1,543 @@
|
||||
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-brand-purple ">Loading event data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-brand-purple mb-4">Event not found</p>
|
||||
<Button onClick={() => navigate('/admin/events')} className="bg-brand-purple hover:bg-[var(--purple-ink)] 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-[var(--neutral-800)] text-brand-purple 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-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Event Attendance
|
||||
</h1>
|
||||
<p className="text-brand-purple mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage RSVPs and track attendance for this event
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={exportToCSV}
|
||||
className="bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] dark:bg-[var(--green-forest)] 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-background border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{event.title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-4 text-brand-purple " 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-[var(--green-light)] dark:bg-[var(--green-forest)]' : 'bg-[var(--neutral-800)]'} 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-background border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-8 w-8 text-brand-purple " />
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
||||
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCheck className="h-8 w-8 text-[var(--green-light)] dark:text-[var(--green-forest)]" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
|
||||
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserX className="h-8 w-8 text-[var(--orange-soft)]" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
|
||||
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<HelpCircle className="h-8 w-8 text-[var(--gold-warm)]" />
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
|
||||
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Check className="h-8 w-8 text-brand-purple " />
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
|
||||
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Actions */}
|
||||
<Card className="p-6 bg-background border-[var(--neutral-800)] 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-brand-purple hover:bg-[var(--purple-ink)] dark:bg-brand-dark-lavender text-white'
|
||||
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
|
||||
}`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
All ({stats.total})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('yes')}
|
||||
variant={activeTab === 'yes' ? 'default' : 'outline'}
|
||||
className={`rounded-xl ${activeTab === 'yes'
|
||||
? 'bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] dark:bg-[var(--green-forest)] text-white'
|
||||
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
|
||||
}`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Yes ({stats.yesCount})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('no')}
|
||||
variant={activeTab === 'no' ? 'default' : 'outline'}
|
||||
className={`rounded-xl ${activeTab === 'no'
|
||||
? 'bg-[var(--orange-soft)] hover:bg-[var(--orange-rust)] text-white'
|
||||
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
|
||||
}`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
No ({stats.noCount})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('maybe')}
|
||||
variant={activeTab === 'maybe' ? 'default' : 'outline'}
|
||||
className={`rounded-xl ${activeTab === 'maybe'
|
||||
? 'bg-[var(--gold-warm)] dark:bg-orange-400 hover:bg-[var(--gold-soft)] text-white'
|
||||
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
|
||||
}`}
|
||||
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-brand-purple " />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-[var(--neutral-800)] rounded-xl"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRsvps.size > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-brand-purple text-white px-3 py-1">
|
||||
{selectedRsvps.size} selected
|
||||
</Badge>
|
||||
<Button
|
||||
onClick={() => handleBulkAttendance(true)}
|
||||
disabled={saving}
|
||||
className="bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] 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-[var(--orange-soft)] hover:bg-[var(--orange-rust)] 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-background border-[var(--neutral-800)] rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[var(--lavender-500)] border-b border-[var(--neutral-800)]">
|
||||
<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-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Email
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
RSVP Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Attendance
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--purple-ink)]" 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-[var(--neutral-800)] hover:bg-[var(--lavender-500)] 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-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{rsvp.user_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{rsvp.user_email}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
className={`${rsvp.rsvp_status === 'yes'
|
||||
? 'bg-[var(--green-light)] dark:bg-[var(--green-forest)]'
|
||||
: rsvp.rsvp_status === 'no'
|
||||
? 'bg-[var(--orange-soft)]'
|
||||
: 'bg-[var(--gold-warm)] text-[var(--purple-ink)]'
|
||||
} 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-[var(--green-light)] dark:bg-[var(--green-forest)] hover:bg-[var(--green-eucalyptus)] 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-[var(--neutral-800)] text-brand-purple hover:bg-[var(--green-light)] dark:bg-[var(--green-forest)] hover:text-white hover:border-[var(--green-light)] dark:bg-[var(--green-forest)] 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-brand-purple " 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-brand-purple " 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,6 @@
|
||||
import React, { useEffect, useState } 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';
|
||||
@@ -7,15 +9,14 @@ 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 navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
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: '',
|
||||
@@ -133,14 +134,15 @@ const AdminEvents = () => {
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Event Management
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Create and manage community events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(hasPermission('events.create') || hasPermission('events.edit')) && (
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
@@ -148,7 +150,7 @@ const AdminEvents = () => {
|
||||
resetForm();
|
||||
setEditingEvent(null);
|
||||
}}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
||||
className="btn-lavender "
|
||||
data-testid="create-event-button"
|
||||
>
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
@@ -156,41 +158,41 @@ const AdminEvents = () => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{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-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<label className="block text-sm font-medium text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Title *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
required
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<label className="block text-sm font-medium text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-lg p-3"
|
||||
className="w-full border-2 border-[var(--neutral-800)] bg-background focus:border-brand-purple rounded-lg p-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<label className="block text-sm font-medium text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Start Date & Time *
|
||||
</label>
|
||||
<Input
|
||||
@@ -198,12 +200,12 @@ const AdminEvents = () => {
|
||||
value={formData.start_at}
|
||||
onChange={(e) => setFormData({ ...formData, start_at: e.target.value })}
|
||||
required
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<label className="block text-sm font-medium text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
End Date & Time *
|
||||
</label>
|
||||
<Input
|
||||
@@ -211,25 +213,25 @@ const AdminEvents = () => {
|
||||
value={formData.end_at}
|
||||
onChange={(e) => setFormData({ ...formData, end_at: e.target.value })}
|
||||
required
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<label className="block text-sm font-medium text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Location *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
required
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<label className="block text-sm font-medium text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Capacity (optional)
|
||||
</label>
|
||||
<Input
|
||||
@@ -237,7 +239,7 @@ const AdminEvents = () => {
|
||||
value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: e.target.value })}
|
||||
placeholder="Leave empty for unlimited"
|
||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -247,9 +249,9 @@ const AdminEvents = () => {
|
||||
id="published"
|
||||
checked={formData.published}
|
||||
onChange={(e) => setFormData({ ...formData, published: e.target.checked })}
|
||||
className="w-4 h-4 text-[#664fa3] border-[#ddd8eb] rounded focus:ring-[#664fa3]"
|
||||
className="w-4 h-4 ui-checkbox text-brand-purple border-[var(--neutral-800)] rounded focus:ring-brand-purple "
|
||||
/>
|
||||
<label htmlFor="published" className="text-sm font-medium text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<label htmlFor="published" className="text-sm font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Publish event (make visible to members)
|
||||
</label>
|
||||
</div>
|
||||
@@ -265,7 +267,7 @@ const AdminEvents = () => {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
className="btn-lavender flex-1"
|
||||
>
|
||||
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||
</Button>
|
||||
@@ -273,30 +275,31 @@ const AdminEvents = () => {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
</div>
|
||||
) : events.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm: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-[#ddd8eb] hover:shadow-lg transition-all"
|
||||
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] 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-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
<Badge
|
||||
className={`${
|
||||
event.published
|
||||
? 'bg-[#81B29A] text-white'
|
||||
|
||||
className={`${event.published
|
||||
? 'border-transparent bg-[var(--green-light)] text-white hover:bg-[var(--green-forest)]'
|
||||
: 'bg-gray-400 text-white'
|
||||
} px-3 py-1 rounded-full`}
|
||||
>
|
||||
@@ -305,18 +308,18 @@ const AdminEvents = () => {
|
||||
</div>
|
||||
|
||||
{/* Event Details */}
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
{event.description && (
|
||||
<p className="text-[#664fa3] mb-4 line-clamp-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4 line-clamp-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex items-center gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{new Date(event.start_at).toLocaleDateString()} at{' '}
|
||||
@@ -326,31 +329,28 @@ const AdminEvents = () => {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex items-center gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex items-center gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<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-[#ddd8eb]">
|
||||
{/* Mark Attendance Button */}
|
||||
<div className="space-y-2 pt-4 border-t border-[var(--neutral-800)]">
|
||||
{/* Manage Attendance Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedEvent(event);
|
||||
setAttendanceDialogOpen(true);
|
||||
}}
|
||||
onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
||||
className="w-full border-[var(--green-light)] text-[var(--green-light)] hover:bg-[var(--green-light)] hover:text-white dark:hover:text-background"
|
||||
data-testid={`mark-attendance-${event.id}`}
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Mark Attendance ({event.rsvp_count || 0} RSVPs)
|
||||
Manage Attendance ({event.rsvp_count || 0} RSVPs)
|
||||
</Button>
|
||||
|
||||
{/* Other Actions */}
|
||||
@@ -359,7 +359,7 @@ const AdminEvents = () => {
|
||||
onClick={() => togglePublish(event)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
|
||||
className="flex-1 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white dark:hover:bg-brand-lavender dark:hover:text-background"
|
||||
data-testid={`toggle-publish-${event.id}`}
|
||||
>
|
||||
{event.published ? (
|
||||
@@ -378,7 +378,7 @@ const AdminEvents = () => {
|
||||
onClick={() => handleEdit(event)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-400 text-gray-600 hover:bg-gray-400 hover:text-white"
|
||||
className="border-gray-400 text-gray-600 dark:text-gray-400 hover:bg-gray-400 dark:hover:text-background hover:text-white"
|
||||
data-testid={`edit-event-${event.id}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
@@ -399,30 +399,22 @@ const AdminEvents = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<Calendar className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Calendar className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Events Yet
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Create your first event to get started!
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"
|
||||
className="btn-lavender 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminFinancials = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [reports, setReports] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -42,7 +44,7 @@ const AdminFinancials = () => {
|
||||
year: new Date().getFullYear(),
|
||||
title: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive'
|
||||
document_type: 'link'
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -67,8 +69,9 @@ const AdminFinancials = () => {
|
||||
year: new Date().getFullYear(),
|
||||
title: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive'
|
||||
document_type: 'link'
|
||||
});
|
||||
setUploadedFile(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -144,7 +147,7 @@ const AdminFinancials = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]">Loading financial reports...</p>
|
||||
<p className="text-brand-purple ">Loading financial reports...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -154,78 +157,88 @@ const AdminFinancials = () => {
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Financial Reports Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage annual financial reports
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission('financials.create') && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
className="btn-lavender flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Report
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reports List */}
|
||||
{reports.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<TrendingUp className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<p className="text-brand-purple text-lg mb-4">No financial reports yet</p>
|
||||
{hasPermission('financials.create') && (
|
||||
<Button onClick={handleCreate} className="bg-brand-purple text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Report
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reports.map(report => (
|
||||
<Card key={report.id} className="p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-4 rounded-xl text-white min-w-[100px] text-center">
|
||||
<div className="bg-light-lavender p-4 rounded-xl min-w-[100px] text-center">
|
||||
<DollarSign className="h-6 w-6 mx-auto mb-1" />
|
||||
<div className="text-2xl font-bold">{report.year}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
||||
{report.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
||||
<Badge variant="outline" className="border-brand-purple text-brand-purple ">
|
||||
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(report.document_url, '_blank')}
|
||||
className="text-[#664fa3] hover:text-[#533a82]"
|
||||
className="text-brand-purple hover:text-[var(--purple-muted)]"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(hasPermission('financials.edit') || hasPermission('financials.delete')) && (
|
||||
<div className="flex gap-2">
|
||||
{hasPermission('financials.edit') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(report)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
className="border-brand-purple text-brand-purple "
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('financials.delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="outline-destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(report)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
className=""
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
@@ -274,14 +287,16 @@ const AdminFinancials = () => {
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||
setUploadedFile(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -298,10 +313,15 @@ const AdminFinancials = () => {
|
||||
required={!selectedReport}
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
{selectedReport && !uploadedFile && (
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Current file will be kept if no new file is selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -310,12 +330,11 @@ const AdminFinancials = () => {
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
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
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -331,7 +350,7 @@ const AdminFinancials = () => {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#664fa3] text-white"
|
||||
className="bg-brand-purple text-white"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : selectedReport ? 'Update' : 'Create'}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } 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';
|
||||
@@ -14,11 +16,12 @@ import {
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react';
|
||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import moment from 'moment';
|
||||
|
||||
const AdminGallery = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [galleryImages, setGalleryImages] = useState([]);
|
||||
@@ -150,23 +153,23 @@ const AdminGallery = () => {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Event Gallery Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Upload and manage photos for event galleries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Event Selection */}
|
||||
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<Card className="p-6 bg-background border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-[#422268] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Label className="text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Select Event
|
||||
</Label>
|
||||
<Select value={selectedEvent || ''} onValueChange={setSelectedEvent}>
|
||||
<SelectTrigger className="border-[#ddd8eb] rounded-xl">
|
||||
<SelectTrigger className="border-[var(--neutral-800)] rounded-xl">
|
||||
<SelectValue placeholder="Choose an event..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -179,7 +182,33 @@ const AdminGallery = () => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedEvent && (
|
||||
{/* Empty State Message */}
|
||||
{events.length === 0 && (
|
||||
<div className="mt-4 p-4 bg-[var(--lavender-300)] dark:bg-brand-lavender/10 dark:border-transparent border-2 border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Events Available
|
||||
</h4>
|
||||
<p className="text-sm text-brand-purple 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="btn-lavender 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 && hasPermission('gallery.upload') && (
|
||||
<div className="pt-4">
|
||||
<input
|
||||
type="file"
|
||||
@@ -192,7 +221,7 @@ const AdminGallery = () => {
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
|
||||
className="btn-lavender "
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{uploading ? (
|
||||
@@ -207,7 +236,7 @@ const AdminGallery = () => {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You can select multiple images. Max {formatFileSize(maxFileSize)} per image.
|
||||
</p>
|
||||
</div>
|
||||
@@ -217,12 +246,12 @@ const AdminGallery = () => {
|
||||
|
||||
{/* Gallery Grid */}
|
||||
{selectedEvent && (
|
||||
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<Card className="p-6 bg-background border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Gallery Images
|
||||
</h2>
|
||||
<Badge className="bg-[#664fa3] text-white px-3 py-1">
|
||||
<Badge variant="purple" className=" px-3 py-1">
|
||||
{galleryImages.length} {galleryImages.length === 1 ? 'image' : 'images'}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -231,7 +260,7 @@ const AdminGallery = () => {
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{galleryImages.map((image) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-[#F8F7FB]">
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-[var(--lavender-500)]">
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt={image.caption || 'Gallery image'}
|
||||
@@ -240,16 +269,20 @@ const AdminGallery = () => {
|
||||
</div>
|
||||
|
||||
{/* Overlay with Actions */}
|
||||
{(hasPermission('gallery.edit') || hasPermission('gallery.delete')) && (
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
||||
{hasPermission('gallery.edit') && (
|
||||
<Button
|
||||
onClick={() => openEditCaption(image)}
|
||||
size="sm"
|
||||
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
|
||||
className="bg-background/90 hover:bg-background text-[var(--purple-ink)] dark:text-[#ddd8eb] rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Caption
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('gallery.delete') && (
|
||||
<Button
|
||||
onClick={() => handleDeleteImage(image.id)}
|
||||
size="sm"
|
||||
@@ -259,12 +292,14 @@ const AdminGallery = () => {
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Caption Preview */}
|
||||
{image.caption && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-[#664fa3] line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{image.caption}
|
||||
</p>
|
||||
</div>
|
||||
@@ -272,7 +307,7 @@ const AdminGallery = () => {
|
||||
|
||||
{/* File Size */}
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{formatFileSize(image.file_size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -281,11 +316,11 @@ const AdminGallery = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<ImageIcon className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<ImageIcon className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Images Yet
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Upload images to create a gallery for this event.
|
||||
</p>
|
||||
</div>
|
||||
@@ -295,16 +330,16 @@ const AdminGallery = () => {
|
||||
|
||||
{/* Edit Caption Dialog */}
|
||||
<Dialog open={!!editingCaption} onOpenChange={() => setEditingCaption(null)}>
|
||||
<DialogContent className="bg-white sm:max-w-[600px]">
|
||||
<DialogContent className="bg-background sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Edit Image Caption
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{editingCaption && (
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-video rounded-xl overflow-hidden bg-[#F8F7FB]">
|
||||
<div className="aspect-video rounded-xl overflow-hidden bg-[var(--lavender-500)]">
|
||||
<img
|
||||
src={editingCaption.image_url}
|
||||
alt="Preview"
|
||||
@@ -313,7 +348,7 @@ const AdminGallery = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Label className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Caption
|
||||
</Label>
|
||||
<Textarea
|
||||
@@ -321,7 +356,7 @@ const AdminGallery = () => {
|
||||
onChange={(e) => setNewCaption(e.target.value)}
|
||||
placeholder="Add a caption for this image..."
|
||||
rows={3}
|
||||
className="border-[#ddd8eb] rounded-xl mt-2"
|
||||
className="border-[var(--neutral-800)] rounded-xl mt-2"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
@@ -332,14 +367,14 @@ const AdminGallery = () => {
|
||||
<Button
|
||||
onClick={() => setEditingCaption(null)}
|
||||
variant="outline"
|
||||
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
|
||||
className="border-[var(--neutral-800)] text-brand-purple rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateCaption}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl"
|
||||
className="bg-brand-purple hover:bg-[var(--purple-ink)] text-white rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Save Caption
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation, 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 { Input } from '../../components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, Search, User, CreditCard, Eye, CheckCircle } from 'lucide-react';
|
||||
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus } from 'lucide-react';
|
||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
|
||||
const AdminMembers = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { hasPermission } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -20,6 +33,13 @@ const AdminMembers = () => {
|
||||
const [statusFilter, setStatusFilter] = useState('active');
|
||||
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
||||
const [statusChanging, setStatusChanging] = useState(null);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingStatusChange, setPendingStatusChange] = useState(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
@@ -70,87 +90,304 @@ const AdminMembers = () => {
|
||||
fetchMembers(); // Refresh list
|
||||
};
|
||||
|
||||
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
|
||||
// Skip confirmation if status didn't actually change
|
||||
if (currentStatus === newStatus) return;
|
||||
|
||||
setPendingStatusChange({ userId, newStatus, user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmStatusChange = async () => {
|
||||
if (!pendingStatusChange) return;
|
||||
|
||||
const { userId, newStatus } = pendingStatusChange;
|
||||
setStatusChanging(userId);
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
||||
toast.success('Member status updated successfully');
|
||||
fetchMembers(); // Refresh list
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update status');
|
||||
} finally {
|
||||
setStatusChanging(null);
|
||||
setPendingStatusChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (filterType) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
let params = {};
|
||||
if (filterType === 'current') {
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
}
|
||||
// filterType === 'all' will export all members without filters
|
||||
|
||||
const response = await api.get('/admin/users/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `members_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
toast.success('Members exported successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to export members');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChangeMessage = () => {
|
||||
if (!pendingStatusChange) return {};
|
||||
|
||||
const { newStatus, user } = pendingStatusChange;
|
||||
const userName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
const messages = {
|
||||
payment_pending: {
|
||||
title: 'Revert to Payment Pending?',
|
||||
description: `This will change ${userName}'s status back to Payment Pending. They will need to complete payment again to become active.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Revert Status',
|
||||
},
|
||||
active: {
|
||||
title: 'Activate Member?',
|
||||
description: `This will activate ${userName}'s membership. They will gain full access to member features and resources.`,
|
||||
variant: 'success',
|
||||
confirmText: 'Yes, Activate',
|
||||
},
|
||||
inactive: {
|
||||
title: 'Deactivate Member?',
|
||||
description: `This will deactivate ${userName}'s membership. They will lose access to member-only features but their data will be preserved.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Deactivate',
|
||||
},
|
||||
canceled: {
|
||||
title: 'Cancel Membership?',
|
||||
description: `This will mark ${userName}'s membership as canceled. This indicates they voluntarily ended their membership. Their subscription will not auto-renew.`,
|
||||
variant: 'danger',
|
||||
confirmText: 'Yes, Cancel Membership',
|
||||
},
|
||||
expired: {
|
||||
title: 'Mark Membership as Expired?',
|
||||
description: `This will mark ${userName}'s membership as expired. This indicates their subscription period has ended without renewal.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Mark as Expired',
|
||||
},
|
||||
};
|
||||
|
||||
return messages[newStatus] || {
|
||||
title: 'Confirm Status Change',
|
||||
description: `Are you sure you want to change ${userName}'s status to ${newStatus}?`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Confirm',
|
||||
};
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
pending_email: { label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_approval: { label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' }
|
||||
pending_email: { label: 'Pending Email', variant: 'orange2' },
|
||||
pending_validation: { label: 'Pending Validation', variant: 'gray' },
|
||||
pre_validated: { label: 'Pre-Validated', variant: 'green' },
|
||||
payment_pending: { label: 'Payment Pending', variant: 'orange' },
|
||||
active: { label: 'Active', variant: 'green' },
|
||||
inactive: { label: 'Inactive', variant: 'gray2' },
|
||||
canceled: { label: 'Canceled', variant: 'red' },
|
||||
expired: { label: 'Expired', variant: 'red2' },
|
||||
abandoned: { label: 'Abandoned', variant: 'gray3' }
|
||||
};
|
||||
|
||||
const statusConfig = config[status] || config.inactive;
|
||||
return (
|
||||
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
|
||||
<Badge variant={statusConfig.variant} className={` px-3 py-1 rounded-full text-sm`}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getReminderInfo = (user) => {
|
||||
const emailReminders = user.email_verification_reminders_sent || 0;
|
||||
const eventReminders = user.event_attendance_reminders_sent || 0;
|
||||
const paymentReminders = user.payment_reminders_sent || 0;
|
||||
const renewalReminders = user.renewal_reminders_sent || 0;
|
||||
const totalReminders = emailReminders + eventReminders + paymentReminders + renewalReminders;
|
||||
|
||||
return {
|
||||
emailReminders,
|
||||
eventReminders,
|
||||
paymentReminders,
|
||||
renewalReminders,
|
||||
totalReminders,
|
||||
lastReminderAt: user.last_email_verification_reminder_at ||
|
||||
user.last_event_attendance_reminder_at ||
|
||||
user.last_payment_reminder_at ||
|
||||
user.last_renewal_reminder_at
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Management
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple dark:text-brand-lavender" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage paying members and their subscriptions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{hasPermission('users.export') && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="btn-util-purple "
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<Download className="h-5 w-5 mr-2 animate-bounce" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="h-5 w-5 mr-2" />
|
||||
Export
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="rounded-xl">
|
||||
<DropdownMenuItem onClick={() => handleExport('all')} className="cursor-pointer">
|
||||
Export All Members
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport('current')} className="cursor-pointer">
|
||||
Export Current View
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{hasPermission('users.import') && (
|
||||
<Button
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
className="btn-util-green "
|
||||
>
|
||||
<Upload className="h-5 w-5 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasPermission('users.invite') && (
|
||||
<Button
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
className="btn-util-purple "
|
||||
>
|
||||
<Mail className="h-5 w-5 mr-2" />
|
||||
Invite Member
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasPermission('users.create') && (
|
||||
<Button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="btn-util-green "
|
||||
>
|
||||
<UserPlus className="h-5 w-5 mr-2" />
|
||||
Create Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{users.length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{users.filter(u => u.status === 'active').length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{users.filter(u => u.status === 'payment_pending').length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Inactive</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{users.filter(u => u.status === 'inactive').length}
|
||||
</p>
|
||||
</Card>
|
||||
<div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
|
||||
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
|
||||
Quick Overview
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||
|
||||
|
||||
<StatCard
|
||||
title="Total Members"
|
||||
value={users.length}
|
||||
icon={Users}
|
||||
iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
|
||||
dataTestId="stat-total-members"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active"
|
||||
value={users.filter(u => u.status === 'active').length}
|
||||
icon={CheckCircle}
|
||||
iconBgClass="text-[var(--green-light)]"
|
||||
dataTestId="stat-active-members"
|
||||
/>
|
||||
<StatCard
|
||||
title="Payment Pending"
|
||||
value={users.filter(u => u.status === 'payment_pending').length}
|
||||
icon={CreditCard}
|
||||
iconBgClass="text-brand-light-orange"
|
||||
dataTestId="stat-payment-pending-members"
|
||||
/>
|
||||
<StatCard
|
||||
title="Inactive"
|
||||
value={users.filter(u => u.status === 'inactive').length}
|
||||
icon={CircleMinus}
|
||||
iconBgClass=" text-brand-pink"
|
||||
dataTestId="stat-inactive-members"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
||||
<div className="grid grid-cols-1 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-[#664fa3]" />
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||
<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-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="search-members-input"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" data-testid="status-filter-select">
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]" 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="pending_validation">Pending Validation</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="canceled">Canceled</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
<SelectItem value="abandoned">Abandoned</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -159,49 +396,106 @@ const AdminMembers = () => {
|
||||
{/* Members List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading members...</p>
|
||||
<p className="text-brand-purple dark:text-brand-lavender " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading members...</p>
|
||||
</div>
|
||||
) : filteredUsers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredUsers.map((user) => (
|
||||
{filteredUsers.map((user) => {
|
||||
const joinedDate = user.member_since || user.created_at;
|
||||
return (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-md transition-shadow"
|
||||
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] 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-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-lg flex-shrink-0">
|
||||
<div className="h-14 w-14 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] 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 text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] " style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
{getStatusBadge(user.status)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple dark:text-brand-lavender " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone}</p>
|
||||
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
<p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
|
||||
{user.referred_by_member_name && (
|
||||
<p>Referred by: {user.referred_by_member_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reminder Info */}
|
||||
{(() => {
|
||||
const reminderInfo = getReminderInfo(user);
|
||||
if (reminderInfo.totalReminders > 0) {
|
||||
return (
|
||||
<div className="mt-4 p-3 bg-[var(--lavender-500)] rounded-lg border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="h-4 w-4 text-[var(--orange-light)]" />
|
||||
<span className="text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{reminderInfo.totalReminders} reminder{reminderInfo.totalReminders !== 1 ? 's' : ''} sent
|
||||
{reminderInfo.totalReminders >= 3 && (
|
||||
<Badge className="ml-2 bg-[var(--orange-light)] text-white px-2 py-0.5 rounded-full text-xs">
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{reminderInfo.emailReminders > 0 && (
|
||||
<p>
|
||||
<Mail className="inline h-3 w-3 mr-1" />
|
||||
{reminderInfo.emailReminders} email verification
|
||||
</p>
|
||||
)}
|
||||
{reminderInfo.eventReminders > 0 && (
|
||||
<p>
|
||||
<Calendar className="inline h-3 w-3 mr-1" />
|
||||
{reminderInfo.eventReminders} event attendance
|
||||
</p>
|
||||
)}
|
||||
{reminderInfo.paymentReminders > 0 && (
|
||||
<p>
|
||||
<Clock className="inline h-3 w-3 mr-1" />
|
||||
{reminderInfo.paymentReminders} payment
|
||||
</p>
|
||||
)}
|
||||
{reminderInfo.renewalReminders > 0 && (
|
||||
<p>
|
||||
<CheckCircle className="inline h-3 w-3 mr-1" />
|
||||
{reminderInfo.renewalReminders} renewal
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{reminderInfo.lastReminderAt && (
|
||||
<p className="mt-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Last reminder: {new Date(reminderInfo.lastReminderAt).toLocaleDateString()} at {new Date(reminderInfo.lastReminderAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Link to={`/admin/users/${user.id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
|
||||
className=""
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View Profile
|
||||
@@ -213,36 +507,49 @@ const AdminMembers = () => {
|
||||
<Button
|
||||
onClick={() => handleActivatePayment(user)}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Activate Payment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
{/* Status Management */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-brand-purple dark:text-brand-lavender whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Change Status:
|
||||
</span>
|
||||
<Select
|
||||
value={user.status}
|
||||
onValueChange={(newStatus) => handleStatusChangeRequest(user.id, user.status, newStatus, user)}
|
||||
disabled={statusChanging === user.id}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 mr-1" />
|
||||
Subscription
|
||||
</Button>
|
||||
)}
|
||||
<SelectTrigger className="w-[180px] h-9 border-[var(--neutral-800)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="canceled">Canceled</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<Users className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Users className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Members Found
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'No members yet'}
|
||||
@@ -257,6 +564,34 @@ const AdminMembers = () => {
|
||||
user={selectedUserForPayment}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
/>
|
||||
|
||||
{/* Status Change Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onConfirm={confirmStatusChange}
|
||||
loading={statusChanging !== null}
|
||||
{...getStatusChangeMessage()}
|
||||
/>
|
||||
|
||||
{/* Create/Invite/Import Dialogs */}
|
||||
<CreateMemberDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={fetchMembers}
|
||||
/>
|
||||
|
||||
<InviteStaffDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
onSuccess={fetchMembers}
|
||||
/>
|
||||
|
||||
<WordPressImportWizard
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
onSuccess={fetchMembers}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminNewsletters = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [newsletters, setNewsletters] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -44,7 +46,7 @@ const AdminNewsletters = () => {
|
||||
description: '',
|
||||
published_date: '',
|
||||
document_url: '',
|
||||
document_type: 'google_docs'
|
||||
document_type: 'link'
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -70,8 +72,9 @@ const AdminNewsletters = () => {
|
||||
description: '',
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
document_url: '',
|
||||
document_type: 'google_docs'
|
||||
document_type: 'link'
|
||||
});
|
||||
setUploadedFile(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -172,7 +175,7 @@ const AdminNewsletters = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]">Loading newsletters...</p>
|
||||
<p className="text-brand-purple ">Loading newsletters...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,37 +185,41 @@ const AdminNewsletters = () => {
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Newsletter Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Create and manage newsletter archive
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission('newsletters.create') && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
className="btn-light-lavender flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Newsletter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Newsletters List */}
|
||||
{newsletters.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<FileText className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<p className="text-brand-purple text-lg mb-4">No newsletters yet</p>
|
||||
{hasPermission('newsletters.create') && (
|
||||
<Button onClick={handleCreate} className="bg-brand-purple text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Newsletter
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{sortedYears.map(year => (
|
||||
<div key={year}>
|
||||
<h2 className="text-xl font-semibold text-[#422268] mb-4 flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-[var(--purple-ink)] mb-4 flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
{year}
|
||||
</h2>
|
||||
@@ -221,48 +228,54 @@ const AdminNewsletters = () => {
|
||||
<Card key={newsletter.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
||||
{newsletter.title}
|
||||
</h3>
|
||||
{newsletter.description && (
|
||||
<p className="text-[#664fa3] mb-3">{newsletter.description}</p>
|
||||
<p className="text-brand-purple mb-3">{newsletter.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-[#DDD8EB] text-[#422268]">
|
||||
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]">
|
||||
{formatDate(newsletter.published_date)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
||||
{newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
|
||||
<Badge variant="outline" className="border-brand-purple text-brand-purple ">
|
||||
{newsletter.document_type === 'upload' ? 'PDF Upload' : 'Link'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(newsletter.document_url, '_blank')}
|
||||
className="text-[#664fa3] hover:text-[#533a82]"
|
||||
className="text-brand-purple hover:text-[var(--purple-muted)]"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(hasPermission('newsletters.edit') || hasPermission('newsletters.delete')) && (
|
||||
<div className="flex gap-2">
|
||||
{hasPermission('newsletters.edit') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(newsletter)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
className="border-brand-purple text-brand-purple "
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('newsletters.delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="outline-destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(newsletter)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
className=""
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
@@ -322,14 +335,16 @@ const AdminNewsletters = () => {
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||
setUploadedFile(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_docs">Google Docs</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -346,10 +361,15 @@ const AdminNewsletters = () => {
|
||||
required={!selectedNewsletter}
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
{selectedNewsletter && !uploadedFile && (
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Current file will be kept if no new file is selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -358,12 +378,11 @@ const AdminNewsletters = () => {
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
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
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_docs' && 'Paste the shareable link to your Google Doc'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
<p className="text-sm text-brand-purple mt-1">
|
||||
Paste the shareable link to your document (Google Docs, Dropbox, PDF URL, etc.)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -379,7 +398,7 @@ const AdminNewsletters = () => {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#664fa3] text-white"
|
||||
className="bg-brand-purple text-white"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : selectedNewsletter ? 'Update' : 'Create'}
|
||||
|
||||
415
src/pages/admin/AdminPermissions.js
Normal file
415
src/pages/admin/AdminPermissions.js
Normal file
@@ -0,0 +1,415 @@
|
||||
import React, { useEffect, useState } 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 { Checkbox } from '../../components/ui/checkbox';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../../components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { Shield, Save, Lock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const AdminPermissions = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [rolePermissions, setRolePermissions] = useState({
|
||||
admin: [],
|
||||
member: [],
|
||||
guest: []
|
||||
});
|
||||
const [selectedPermissions, setSelectedPermissions] = useState({
|
||||
admin: [],
|
||||
member: [],
|
||||
guest: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState('admin');
|
||||
const [expandedModules, setExpandedModules] = useState({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if there are unsaved changes
|
||||
const changed = ['admin', 'member', 'guest'].some(role => {
|
||||
const current = selectedPermissions[role].slice().sort();
|
||||
const original = rolePermissions[role].slice().sort();
|
||||
return JSON.stringify(current) !== JSON.stringify(original);
|
||||
});
|
||||
setHasChanges(changed);
|
||||
}, [selectedPermissions, rolePermissions]);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
// Fetch all permissions
|
||||
const permsResponse = await api.get('/admin/permissions');
|
||||
setPermissions(permsResponse.data);
|
||||
|
||||
// Fetch permissions for each role
|
||||
const roles = ['admin', 'member', 'guest'];
|
||||
const rolePermsData = {};
|
||||
const selectedPermsData = {};
|
||||
|
||||
for (const role of roles) {
|
||||
const response = await api.get(`/admin/permissions/roles/${role}`);
|
||||
rolePermsData[role] = response.data.map(p => p.code);
|
||||
selectedPermsData[role] = response.data.map(p => p.code);
|
||||
}
|
||||
|
||||
setRolePermissions(rolePermsData);
|
||||
setSelectedPermissions(selectedPermsData);
|
||||
|
||||
// Expand all modules by default
|
||||
const modules = [...new Set(permsResponse.data.map(p => p.module))];
|
||||
const expanded = {};
|
||||
modules.forEach(module => {
|
||||
expanded[module] = true;
|
||||
});
|
||||
setExpandedModules(expanded);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch permissions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (role, permissionCode) => {
|
||||
setSelectedPermissions(prev => {
|
||||
const current = prev[role] || [];
|
||||
if (current.includes(permissionCode)) {
|
||||
return {
|
||||
...prev,
|
||||
[role]: current.filter(p => p !== permissionCode)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
[role]: [...current, permissionCode]
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleModule = (role, module) => {
|
||||
const modulePerms = permissions
|
||||
.filter(p => p.module === module)
|
||||
.map(p => p.code);
|
||||
|
||||
const allSelected = modulePerms.every(code =>
|
||||
selectedPermissions[role].includes(code)
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
// Deselect all module permissions
|
||||
setSelectedPermissions(prev => ({
|
||||
...prev,
|
||||
[role]: prev[role].filter(p => !modulePerms.includes(p))
|
||||
}));
|
||||
} else {
|
||||
// Select all module permissions
|
||||
setSelectedPermissions(prev => ({
|
||||
...prev,
|
||||
[role]: [...new Set([...prev[role], ...modulePerms])]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmSave = async () => {
|
||||
setSaving(true);
|
||||
setShowConfirmDialog(false);
|
||||
|
||||
try {
|
||||
// Save permissions for each role
|
||||
await Promise.all([
|
||||
api.put(`/admin/permissions/roles/${selectedRole}`, {
|
||||
permission_codes: selectedPermissions[selectedRole]
|
||||
})
|
||||
]);
|
||||
|
||||
// Update original state
|
||||
setRolePermissions(prev => ({
|
||||
...prev,
|
||||
[selectedRole]: [...selectedPermissions[selectedRole]]
|
||||
}));
|
||||
|
||||
toast.success(`Permissions updated for ${selectedRole}`);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update permissions');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleModuleExpansion = (module) => {
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[module]: !prev[module]
|
||||
}));
|
||||
};
|
||||
|
||||
const groupedPermissions = permissions.reduce((acc, perm) => {
|
||||
if (!acc[perm.module]) {
|
||||
acc[perm.module] = [];
|
||||
}
|
||||
acc[perm.module].push(perm);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const getModuleProgress = (role, module) => {
|
||||
const modulePerms = groupedPermissions[module] || [];
|
||||
const selected = modulePerms.filter(p =>
|
||||
selectedPermissions[role].includes(p.code)
|
||||
).length;
|
||||
return `${selected}/${modulePerms.length}`;
|
||||
};
|
||||
|
||||
const isModuleFullySelected = (role, module) => {
|
||||
const modulePerms = groupedPermissions[module] || [];
|
||||
return modulePerms.every(p => selectedPermissions[role].includes(p.code));
|
||||
};
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
const config = {
|
||||
admin: { label: 'Admin', color: 'bg-[var(--green-light)]', icon: Shield },
|
||||
member: { label: 'Member', color: 'bg-brand-purple ', icon: Shield },
|
||||
guest: { label: 'Guest', color: 'bg-gray-400', icon: Shield }
|
||||
};
|
||||
|
||||
const roleConfig = config[role] || config.admin;
|
||||
const Icon = roleConfig.icon;
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${roleConfig.color} text-white px-4 py-2 rounded-full`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="font-semibold">{roleConfig.label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading permissions...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasPermission('permissions.assign')) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<Lock className="h-20 w-20 text-red-500 mx-auto mb-6" />
|
||||
<h2 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You don't have permission to manage role permissions.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Only Superadmins can access this page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Permission Management
|
||||
</h1>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Configure granular permissions for each role. Superadmin always has all permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Role Tabs */}
|
||||
<Tabs value={selectedRole} onValueChange={setSelectedRole} className="mb-8">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-8">
|
||||
<TabsTrigger value="admin" className="text-lg py-3">
|
||||
{getRoleBadge('admin')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="member" className="text-lg py-3">
|
||||
{getRoleBadge('member')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="guest" className="text-lg py-3">
|
||||
{getRoleBadge('guest')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{['admin', 'member', 'guest'].map(role => (
|
||||
<TabsContent key={role} value={role}>
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-8">
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Total Permissions
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{permissions.length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Assigned
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{selectedPermissions[role].length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Modules
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{Object.keys(groupedPermissions).length}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Permissions by Module */}
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedPermissions).map(([module, perms]) => (
|
||||
<Card key={module} className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden">
|
||||
{/* Module Header */}
|
||||
<div
|
||||
className="p-6 bg-gradient-to-r from-[var(--neutral-800)] to-white cursor-pointer hover:from-[var(--neutral-600)] transition-colors"
|
||||
onClick={() => toggleModuleExpansion(module)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Checkbox
|
||||
checked={isModuleFullySelected(role, module)}
|
||||
onCheckedChange={() => toggleModule(role, module)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-6 w-6 border-2 border-brand-purple data-[state=checked]:bg-brand-purple "
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] capitalize" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{module}
|
||||
</h3>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{getModuleProgress(role, module)} permissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{expandedModules[module] ? (
|
||||
<ChevronUp className="h-6 w-6 text-brand-purple " />
|
||||
) : (
|
||||
<ChevronDown className="h-6 w-6 text-brand-purple " />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Permissions */}
|
||||
{expandedModules[module] && (
|
||||
<div className="p-6 pt-0">
|
||||
<div className="grid md:grid-cols-2 gap-4 mt-4">
|
||||
{perms.map(perm => (
|
||||
<div
|
||||
key={perm.code}
|
||||
className="flex items-start gap-4 p-4 rounded-xl hover:bg-[var(--lavender-700)] transition-colors border border-transparent hover:border-[var(--neutral-800)]"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPermissions[role].includes(perm.code)}
|
||||
onCheckedChange={() => togglePermission(role, perm.code)}
|
||||
className="mt-1 h-5 w-5 border-2 border-brand-purple data-[state=checked]:bg-brand-purple "
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{perm.name}
|
||||
</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{perm.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{perm.code}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Superadmin Note */}
|
||||
<Card className="p-6 bg-gradient-to-r from-brand-purple to-[var(--purple-ink)] rounded-2xl border-none mb-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<Lock className="h-6 w-6 text-white flex-shrink-0 mt-1" />
|
||||
<div className="text-white">
|
||||
<p className="font-semibold mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Superadmin Permissions
|
||||
</p>
|
||||
<p className="text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Superadmins automatically have all permissions and cannot be restricted. This ensures you can never lock yourself out of the system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-14 px-8 bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white rounded-full shadow-lg text-lg font-semibold"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="rounded-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Confirm Permission Changes
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Are you sure you want to update permissions for <span className="font-semibold capitalize">{selectedRole}</span>?
|
||||
This will immediately affect all users with this role.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmSave}
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPermissions;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminPlans = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [filteredPlans, setFilteredPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -129,46 +131,48 @@ const AdminPlans = () => {
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Plans
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage membership plans and pricing.
|
||||
</p>
|
||||
</div>
|
||||
{hasPermission('subscriptions.plans') && (
|
||||
<Button
|
||||
onClick={handleCreatePlan}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
||||
className="btn-lavender "
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Plans</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Plans</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{plans.length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Plans</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Plans</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{plans.filter(p => p.active).length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Subscribers</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Subscribers</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{plans.reduce((sum, p) => sum + (p.subscriber_count || 0), 0)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Revenue (Annual Est.)</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Revenue (Annual Est.)</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(
|
||||
plans.reduce((sum, p) => {
|
||||
const annualPrice = p.billing_cycle === 'yearly'
|
||||
@@ -182,19 +186,19 @@ const AdminPlans = () => {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
||||
<div className="grid grid-cols-1 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-[#664fa3]" />
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||
<Input
|
||||
placeholder="Search plans..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-12 h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -209,56 +213,54 @@ const AdminPlans = () => {
|
||||
{/* Plans Grid */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||
</div>
|
||||
) : filteredPlans.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm: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-[#ddd8eb] hover:border-[#664fa3]'
|
||||
className={`p-6 bg-background rounded-2xl border-2 transition-all hover:shadow-lg ${plan.active
|
||||
? 'border-[var(--neutral-800)] hover:border-brand-purple '
|
||||
: 'border-gray-400 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Header with badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge
|
||||
className={`${
|
||||
plan.active
|
||||
? 'bg-[#81B29A] text-white'
|
||||
className={`${plan.active
|
||||
? 'bg-[var(--green-light)] text-white'
|
||||
: 'bg-gray-400 text-white'
|
||||
}`}
|
||||
>
|
||||
{plan.active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
{plan.subscriber_count > 0 && (
|
||||
<Badge className="bg-[#DDD8EB] text-[#422268]">
|
||||
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
{plan.subscriber_count}
|
||||
</Badge>
|
||||
)}
|
||||
{plan.custom_cycle_enabled && (
|
||||
<Badge className="bg-[#664fa3] text-white">
|
||||
<Badge className="bg-brand-purple text-white">
|
||||
Custom Dates
|
||||
</Badge>
|
||||
)}
|
||||
{plan.allow_donation && (
|
||||
<Badge className="bg-[#ff9e77] text-white">
|
||||
<Badge className="bg-[var(--orange-light)] text-white">
|
||||
Donations Enabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan Name */}
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{plan.description && (
|
||||
<p className="text-sm text-[#664fa3] mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -266,32 +268,33 @@ const AdminPlans = () => {
|
||||
{/* Price */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-3xl font-bold text-[#ff9e77]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="text-3xl font-bold text-[var(--orange-light)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(plan.minimum_price_cents || plan.price_cents)}
|
||||
</div>
|
||||
{plan.suggested_price_cents && plan.suggested_price_cents > plan.minimum_price_cents && (
|
||||
<div className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
(Suggested: {formatPrice(plan.suggested_price_cents)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{getBillingCycleLabel(plan.billing_cycle)}
|
||||
</p>
|
||||
{plan.custom_cycle_enabled && (
|
||||
<p className="text-xs text-[#664fa3] font-mono mt-1">
|
||||
<p className="text-xs text-brand-purple font-mono mt-1">
|
||||
{formatCustomCycleDates(plan)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
|
||||
{hasPermission('subscriptions.plans') && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[var(--neutral-800)]">
|
||||
<Button
|
||||
onClick={() => handleEditPlan(plan)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full"
|
||||
className="flex-1 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white rounded-full dark:hover:text-background"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
@@ -300,17 +303,18 @@ const AdminPlans = () => {
|
||||
onClick={() => handleDeleteClick(plan)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-full"
|
||||
className="flex-1 border-red-500 text-red-500 hover:bg-red-500 dark:hover:bg-red-500/10 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-[#664fa3] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-brand-purple mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Cannot delete plan with active subscribers
|
||||
</p>
|
||||
)}
|
||||
@@ -319,19 +323,19 @@ const AdminPlans = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<CreditCard className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CreditCard className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Plans Found
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || activeFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'Create your first subscription plan to get started'}
|
||||
</p>
|
||||
{!searchQuery && activeFilter === 'all' && (
|
||||
{!searchQuery && activeFilter === 'all' && hasPermission('subscriptions.plans') && (
|
||||
<Button
|
||||
onClick={handleCreatePlan}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Plan
|
||||
@@ -351,11 +355,11 @@ const AdminPlans = () => {
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{deleteDialogOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="p-6 sm:p-8 bg-white rounded-2xl max-w-md w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 sm:p-8 bg-background rounded-2xl max-w-md w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Delete Plan
|
||||
</h2>
|
||||
<p className="text-sm sm:text-base text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm sm:text-base text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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>
|
||||
|
||||
498
src/pages/admin/AdminRoles.js
Normal file
498
src/pages/admin/AdminRoles.js
Normal file
@@ -0,0 +1,498 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../../components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { Shield, Plus, Edit, Trash2, Lock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const AdminRoles = () => {
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [selectedRole, setSelectedRole] = useState(null);
|
||||
const [rolePermissions, setRolePermissions] = useState([]);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
|
||||
const [expandedModules, setExpandedModules] = useState({});
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
fetchPermissions();
|
||||
}, []);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/roles');
|
||||
setRoles(response.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch roles');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/permissions');
|
||||
setPermissions(response.data);
|
||||
|
||||
// Expand all modules by default
|
||||
const modules = [...new Set(response.data.map(p => p.module))];
|
||||
const expanded = {};
|
||||
modules.forEach(module => {
|
||||
expanded[module] = true;
|
||||
});
|
||||
setExpandedModules(expanded);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch permissions');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRolePermissions = async (roleId) => {
|
||||
try {
|
||||
const response = await api.get(`/admin/roles/${roleId}/permissions`);
|
||||
setRolePermissions(response.data.permissions);
|
||||
setSelectedPermissions(response.data.permissions.map(p => p.code));
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch role permissions');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
try {
|
||||
await api.post('/admin/roles', {
|
||||
code: formData.code,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
permission_codes: formData.permissions
|
||||
});
|
||||
toast.success('Role created successfully');
|
||||
setShowCreateModal(false);
|
||||
setFormData({ code: '', name: '', description: '', permissions: [] });
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to create role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async () => {
|
||||
try {
|
||||
await api.put(`/admin/roles/${selectedRole.id}`, {
|
||||
name: formData.name,
|
||||
description: formData.description
|
||||
});
|
||||
toast.success('Role updated successfully');
|
||||
setShowEditModal(false);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async () => {
|
||||
try {
|
||||
await api.delete(`/admin/roles/${selectedRole.id}`);
|
||||
toast.success('Role deleted successfully');
|
||||
setShowDeleteDialog(false);
|
||||
setSelectedRole(null);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
try {
|
||||
await api.put(`/admin/roles/${selectedRole.id}/permissions`, {
|
||||
permission_codes: selectedPermissions
|
||||
});
|
||||
toast.success('Permissions updated successfully');
|
||||
setShowPermissionsModal(false);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to update permissions');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (permissionCode) => {
|
||||
setSelectedPermissions(prev => {
|
||||
if (prev.includes(permissionCode)) {
|
||||
return prev.filter(p => p !== permissionCode);
|
||||
} else {
|
||||
return [...prev, permissionCode];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleModule = (module) => {
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[module]: !prev[module]
|
||||
}));
|
||||
};
|
||||
|
||||
const groupPermissionsByModule = () => {
|
||||
const grouped = {};
|
||||
permissions.forEach(perm => {
|
||||
if (!grouped[perm.module]) {
|
||||
grouped[perm.module] = [];
|
||||
}
|
||||
grouped[perm.module].push(perm);
|
||||
});
|
||||
return grouped;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-gray-500">Loading roles...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groupedPermissions = groupPermissionsByModule();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 ">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center ">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--purple-ink)]">Role Management</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Create and manage custom roles with specific permissions
|
||||
</p>
|
||||
</div>
|
||||
<Button className="btn-lavender " onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Roles Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{roles.map(role => (
|
||||
<Card key={role.id} className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Shield className="w-5 h-5 text-blue-600 mr-2" />
|
||||
<div>
|
||||
<h3 className="font-semibold">{role.name}</h3>
|
||||
<p className="text-sm text-gray-500">{role.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
{role.is_system_role && (
|
||||
<Lock className="w-4 h-4 text-gray-400" title="System Role" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4 min-h-[40px]">
|
||||
{role.description || 'No description'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{role.permission_count} permissions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedRole(role);
|
||||
fetchRolePermissions(role.id);
|
||||
setShowPermissionsModal(true);
|
||||
}}
|
||||
>
|
||||
Manage Permissions
|
||||
</Button>
|
||||
{!role.is_system_role && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedRole(role);
|
||||
setFormData({
|
||||
name: role.name,
|
||||
description: role.description || ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setSelectedRole(role);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create Role Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto scrollbar-dashboard scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Role</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a custom role with specific permissions for your team members.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Role Code *</Label>
|
||||
<Input
|
||||
placeholder="e.g., content_editor, finance_manager"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Lowercase, no spaces. Used internally to identify the role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Role Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g., Content Editor, Finance Manager"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
placeholder="Describe what this role can do..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Permissions</Label>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Select permissions for this role. You can also add permissions later.
|
||||
</p>
|
||||
<div className="border rounded-lg p-4 max-h-64 overflow-y-auto scrollbar-dashboard">
|
||||
{Object.entries(groupedPermissions).map(([module, perms]) => (
|
||||
<div key={module} className="mb-4">
|
||||
<button
|
||||
onClick={() => toggleModule(module)}
|
||||
className="flex items-center w-full text-left font-medium mb-2 hover:text-blue-600"
|
||||
>
|
||||
{expandedModules[module] ? (
|
||||
<ChevronUp className="w-4 h-4 mr-1" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
|
||||
</button>
|
||||
{expandedModules[module] && (
|
||||
<div className="space-y-2 ml-5">
|
||||
{perms.map(perm => (
|
||||
<div key={perm.code} className="flex items-center">
|
||||
<Checkbox
|
||||
checked={formData.permissions.includes(perm.code)}
|
||||
onCheckedChange={() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes(perm.code)
|
||||
? prev.permissions.filter(p => p !== perm.code)
|
||||
: [...prev.permissions, perm.code]
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<label className="ml-2 text-sm">
|
||||
<span className="font-medium">{perm.name}</span>
|
||||
<span className="text-gray-500 ml-2">({perm.code})</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateRole}>
|
||||
Create Role
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Role Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent >
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Role</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update role name and description. Code cannot be changed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Role Code</Label>
|
||||
<Input value={selectedRole?.code || ''} disabled />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Role Name *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowEditModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateRole}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Manage Permissions Modal */}
|
||||
<Dialog open={showPermissionsModal} onOpenChange={setShowPermissionsModal}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Permissions: {selectedRole?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which permissions this role should have.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-lg p-4">
|
||||
{Object.entries(groupedPermissions).map(([module, perms]) => (
|
||||
<div key={module} className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleModule(module)}
|
||||
className="flex items-center w-full text-left font-medium text-lg mb-3 hover:text-blue-600"
|
||||
>
|
||||
{expandedModules[module] ? (
|
||||
<ChevronUp className="w-5 h-5 mr-2" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 mr-2" />
|
||||
)}
|
||||
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
|
||||
</button>
|
||||
{expandedModules[module] && (
|
||||
<div className="space-y-3 ml-7">
|
||||
{perms.map(perm => (
|
||||
<div key={perm.code} className="flex items-start">
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(perm.code)}
|
||||
onCheckedChange={() => togglePermission(perm.code)}
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<label className="font-medium text-sm">
|
||||
{perm.name}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">{perm.description}</p>
|
||||
<span className="text-xs text-gray-400">{perm.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPermissionsModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSavePermissions}>
|
||||
Save Permissions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Role?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the role "{selectedRole?.name}"?
|
||||
This action cannot be undone. Users with this role will need to be reassigned.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteRole} className="bg-red-600">
|
||||
Delete Role
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminRoles;
|
||||
506
src/pages/admin/AdminSettings.js
Normal file
506
src/pages/admin/AdminSettings.js
Normal file
@@ -0,0 +1,506 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { AlertCircle, CheckCircle, Settings as SettingsIcon, RefreshCw, Zap, Edit, Save, X, Copy, Eye, EyeOff, ExternalLink } from 'lucide-react';
|
||||
import api from '../../utils/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function AdminSettings() {
|
||||
const [stripeStatus, setStripeStatus] = useState(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
secret_key: '',
|
||||
webhook_secret: ''
|
||||
});
|
||||
|
||||
// Show/hide sensitive values
|
||||
const [showSecretKey, setShowSecretKey] = useState(false);
|
||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStripeStatus();
|
||||
}, []);
|
||||
|
||||
const fetchStripeStatus = async () => {
|
||||
setLoadingStatus(true);
|
||||
try {
|
||||
const response = await api.get('/admin/settings/stripe/status');
|
||||
setStripeStatus(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Stripe status:', error);
|
||||
toast.error('Failed to load Stripe status');
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const response = await api.post('/admin/settings/stripe/test-connection');
|
||||
toast.success(response.data.message);
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.detail || 'Connection test failed';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
setIsEditing(true);
|
||||
setFormData({
|
||||
secret_key: '',
|
||||
webhook_secret: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setFormData({
|
||||
secret_key: '',
|
||||
webhook_secret: ''
|
||||
});
|
||||
setShowSecretKey(false);
|
||||
setShowWebhookSecret(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate inputs
|
||||
if (!formData.secret_key || !formData.webhook_secret) {
|
||||
toast.error('Both Secret Key and Webhook Secret are required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.secret_key.startsWith('sk_test_') && !formData.secret_key.startsWith('sk_live_')) {
|
||||
toast.error('Invalid Secret Key format. Must start with sk_test_ or sk_live_');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.webhook_secret.startsWith('whsec_')) {
|
||||
toast.error('Invalid Webhook Secret format. Must start with whsec_');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put('/admin/settings/stripe', formData);
|
||||
toast.success('Stripe settings updated successfully');
|
||||
setIsEditing(false);
|
||||
setFormData({
|
||||
secret_key: '',
|
||||
webhook_secret: ''
|
||||
});
|
||||
setShowSecretKey(false);
|
||||
setShowWebhookSecret(false);
|
||||
// Refresh status
|
||||
await fetchStripeStatus();
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.detail || 'Failed to update Stripe settings';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text, label) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
};
|
||||
|
||||
if (loadingStatus) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-brand-purple" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<SettingsIcon className="h-8 w-8 text-brand-purple" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage system configuration and integrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stripe Integration Card */}
|
||||
<Card className="border-2 border-[var(--lavender-200)] shadow-sm">
|
||||
<CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-brand-purple" />
|
||||
Stripe Integration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Payment processing and subscription management
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
onClick={handleEditClick}
|
||||
variant="outline"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-[#f1eef9] rounded-full"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{isEditing ? (
|
||||
/* Edit Mode */
|
||||
<div className="space-y-6">
|
||||
{/* Secret Key Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secret_key">Stripe Secret Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="secret_key"
|
||||
type={showSecretKey ? 'text' : 'password'}
|
||||
value={formData.secret_key}
|
||||
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
||||
placeholder="sk_test_... or sk_live_..."
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecretKey(!showSecretKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showSecretKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Get this from your Stripe Dashboard → Developers → API keys
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Webhook Secret Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook_secret">Stripe Webhook Secret</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="webhook_secret"
|
||||
type={showWebhookSecret ? 'text' : 'password'}
|
||||
value={formData.webhook_secret}
|
||||
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })}
|
||||
placeholder="whsec_..."
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWebhookSecret(!showWebhookSecret)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showWebhookSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Get this from your Stripe Dashboard → Developers → Webhooks → Add endpoint
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t-2 border-gray-100">
|
||||
<Button
|
||||
onClick={handleCancelEdit}
|
||||
variant="outline"
|
||||
className="border-2 border-gray-300 rounded-full"
|
||||
disabled={saving}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-brand-purple hover:bg-[var(--purple-dark)] text-white rounded-full"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View Mode */
|
||||
<>
|
||||
{/* Status Display */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Configuration Status</p>
|
||||
<p className="text-sm text-gray-600">Credentials stored in database (encrypted)</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stripeStatus?.configured ? (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-green-600 font-semibold">Configured</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||
<span className="text-amber-600 font-semibold">Not Configured</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stripeStatus?.configured && (
|
||||
<>
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Environment</p>
|
||||
<p className="text-sm text-gray-600">Detected from secret key prefix</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
|
||||
stripeStatus.environment === 'live'
|
||||
? 'bg-green-100 text-green-800 border-2 border-green-300'
|
||||
: 'bg-blue-100 text-blue-800 border-2 border-blue-300'
|
||||
}`}>
|
||||
{stripeStatus.environment === 'live' ? 'Live' : 'Test'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Secret Key</p>
|
||||
<p className="text-sm text-gray-600 font-mono">
|
||||
{stripeStatus.secret_key_prefix}...
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Webhook Secret</p>
|
||||
<p className="text-sm text-gray-600">Webhook endpoint configuration</p>
|
||||
</div>
|
||||
{stripeStatus.webhook_secret_set ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-green-600 font-semibold text-sm">Set</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||
<span className="text-amber-600 font-semibold text-sm">Not Set</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Webhook URL */}
|
||||
<div className="p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Webhook URL
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 mb-2">
|
||||
Configure this webhook endpoint in your Stripe Dashboard:
|
||||
</p>
|
||||
<div className="bg-white p-2 rounded border border-blue-300 font-mono text-sm break-all">
|
||||
{stripeStatus.webhook_url}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => copyToClipboard(stripeStatus.webhook_url, 'Webhook URL')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-blue-600">
|
||||
<p className="font-semibold mb-1">Webhook Events to Configure:</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="font-medium text-blue-700">✅ Required (Configure in Stripe):</p>
|
||||
<ul className="list-disc list-inside ml-2">
|
||||
<li>checkout.session.completed - Handles subscriptions & donations</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="opacity-80">
|
||||
<p className="font-medium text-blue-700">🔔 Automatically Triggered:</p>
|
||||
<ul className="list-disc list-inside ml-2 text-xs">
|
||||
<li>payment_intent.created</li>
|
||||
<li>payment_intent.succeeded</li>
|
||||
<li>charge.succeeded</li>
|
||||
<li>charge.updated</li>
|
||||
</ul>
|
||||
<p className="text-xs italic mt-1">These fire automatically with checkout.session.completed</p>
|
||||
</div>
|
||||
<div className="opacity-70">
|
||||
<p className="font-medium text-blue-700">🔄 Coming Soon (Recurring Subscriptions):</p>
|
||||
<ul className="list-disc list-inside ml-2">
|
||||
<li>invoice.payment_succeeded</li>
|
||||
<li>invoice.payment_failed</li>
|
||||
<li>customer.subscription.updated</li>
|
||||
<li>customer.subscription.deleted</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration Instructions (Not Configured) */}
|
||||
{!stripeStatus?.configured && (
|
||||
<>
|
||||
<div className="p-4 bg-amber-50 border-2 border-amber-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-amber-900 mb-2">Configuration Required</p>
|
||||
<p className="text-amber-700 mb-2">
|
||||
Click "Edit Settings" above to configure your Stripe credentials.
|
||||
</p>
|
||||
<p className="text-amber-700">
|
||||
Get your API keys from{' '}
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold underline"
|
||||
>
|
||||
Stripe Dashboard
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook URL Info (Always visible) */}
|
||||
<div className="p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Webhook URL Configuration
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 mb-2">
|
||||
After configuring your API keys, set up this webhook endpoint in your Stripe Dashboard:
|
||||
</p>
|
||||
<div className="bg-white p-2 rounded border border-blue-300 font-mono text-sm break-all">
|
||||
{stripeStatus?.webhook_url || 'http://localhost:8000/api/webhooks/stripe'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => copyToClipboard(stripeStatus?.webhook_url || 'http://localhost:8000/api/webhooks/stripe', 'Webhook URL')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-blue-600">
|
||||
<p className="font-semibold mb-1">Webhook Events to Configure:</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="font-medium text-blue-700">✅ Required (Configure in Stripe):</p>
|
||||
<ul className="list-disc list-inside ml-2">
|
||||
<li>checkout.session.completed - Handles subscriptions & donations</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="opacity-80">
|
||||
<p className="font-medium text-blue-700">🔔 Automatically Triggered:</p>
|
||||
<ul className="list-disc list-inside ml-2 text-xs">
|
||||
<li>payment_intent.created</li>
|
||||
<li>payment_intent.succeeded</li>
|
||||
<li>charge.succeeded</li>
|
||||
<li>charge.updated</li>
|
||||
</ul>
|
||||
<p className="text-xs italic mt-1">These fire automatically with checkout.session.completed</p>
|
||||
</div>
|
||||
<div className="opacity-70">
|
||||
<p className="font-medium text-blue-700">🔄 Coming Soon (Recurring Subscriptions):</p>
|
||||
<ul className="list-disc list-inside ml-2">
|
||||
<li>invoice.payment_succeeded</li>
|
||||
<li>invoice.payment_failed</li>
|
||||
<li>customer.subscription.updated</li>
|
||||
<li>customer.subscription.deleted</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Test Connection Button */}
|
||||
{stripeStatus?.configured && (
|
||||
<div className="flex justify-end gap-3 pt-4 border-t-2 border-gray-100">
|
||||
<Button
|
||||
onClick={fetchStripeStatus}
|
||||
variant="outline"
|
||||
className="border-2 border-gray-300 rounded-full"
|
||||
disabled={loadingStatus}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loadingStatus ? 'animate-spin' : ''}`} />
|
||||
Refresh Status
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing}
|
||||
className="bg-brand-purple hover:bg-[var(--purple-dark)] text-white rounded-full"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Future Settings Sections Placeholder */}
|
||||
<div className="mt-6 p-6 border-2 border-dashed border-gray-300 rounded-lg text-center text-gray-500">
|
||||
<p className="text-sm">Additional settings sections will be added here</p>
|
||||
<p className="text-xs mt-1">(Email, Storage, Notifications, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import React, { useEffect, useState } 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 { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import CreateStaffDialog from '../../components/CreateStaffDialog';
|
||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
|
||||
import { toast } from 'sonner';
|
||||
import { UserCog, Search, Shield } from 'lucide-react';
|
||||
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
|
||||
const AdminStaff = () => {
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission, user } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('staff-list');
|
||||
|
||||
// Staff roles (non-guest, non-member)
|
||||
const STAFF_ROLES = ['admin'];
|
||||
// Staff roles (non-guest, non-member) - includes all admin-type roles
|
||||
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
|
||||
|
||||
useEffect(() => {
|
||||
fetchStaff();
|
||||
@@ -60,18 +71,44 @@ const AdminStaff = () => {
|
||||
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 config = {
|
||||
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
||||
admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
|
||||
moderator: { label: 'Moderator', className: 'bg-[#DDD8EB] text-[#422268]' },
|
||||
staff: { label: 'Staff', className: 'bg-gray-200 text-gray-700' },
|
||||
media: { label: 'Media', className: 'bg-gray-400 text-white' }
|
||||
superadmin: { label: 'Superadmin', variant: 'purple' },
|
||||
admin: { label: 'Admin', variant: 'green' },
|
||||
moderator: { label: 'Moderator', variant: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' },
|
||||
staff: { label: 'Staff', variant: 'gray' },
|
||||
media: { label: 'Media', variant: 'gray2' }
|
||||
};
|
||||
|
||||
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`}>
|
||||
<Badge variant={roleConfig.variant} className={`${roleConfig.className} px-3 py-1 rounded-full text-sm`}>
|
||||
<Shield className="h-3 w-3 mr-1 inline" />
|
||||
{roleConfig.label}
|
||||
</Badge>
|
||||
@@ -80,13 +117,13 @@ const AdminStaff = () => {
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
active: { label: 'Active', variant: 'green' },
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white ' }
|
||||
};
|
||||
|
||||
const statusConfig = config[status] || config.inactive;
|
||||
return (
|
||||
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
|
||||
<Badge variant={statusConfig.variant} className={` px-3 py-1 rounded-full text-sm`}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -95,57 +132,95 @@ const AdminStaff = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Staff Management
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage internal team members and their roles.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{hasPermission('users.create') && (
|
||||
<Button
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
className="btn-util-purple h-12 px-6"
|
||||
>
|
||||
<Mail className="h-5 w-5 mr-2" />
|
||||
Invite Staff
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('users.create') && (
|
||||
<Button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="btn-util-green h-12 px-6"
|
||||
>
|
||||
<UserPlus className="h-5 w-5 mr-2" />
|
||||
Create Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Staff</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Staff</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{users.length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Admins</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Admins</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{users.filter(u => ['admin', 'superadmin'].includes(u.role)).length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Moderators</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Moderators</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{users.filter(u => u.role === 'moderator').length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{users.filter(u => u.status === 'active').length}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-8">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-8">
|
||||
<TabsTrigger value="staff-list" className="text-lg py-3">
|
||||
<UserCog className="h-5 w-5 mr-2" />
|
||||
Staff Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pending-invitations" className="text-lg py-3 ">
|
||||
<Mail className="h-5 w-5 mr-2" />
|
||||
Pending Invitations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="staff-list">
|
||||
{/* Filters */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] 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-[#664fa3]" />
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||
<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-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="search-staff-input"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" data-testid="role-filter-select">
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]" data-testid="role-filter-select">
|
||||
<SelectValue placeholder="Filter by role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -163,59 +238,130 @@ const AdminStaff = () => {
|
||||
{/* Staff List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading staff...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading staff...</p>
|
||||
</div>
|
||||
) : filteredUsers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredUsers.map((user) => (
|
||||
{filteredUsers.map((user) => {
|
||||
const joinedDate = user.member_since || user.created_at;
|
||||
return (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-md transition-shadow"
|
||||
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] 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-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-lg flex-shrink-0">
|
||||
<div className="h-14 w-14 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] 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 text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone}</p>
|
||||
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
<p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
|
||||
{user.last_login && (
|
||||
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
variant="outline"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-4 py-2"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Manage
|
||||
</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 dark:hover:bg-orange-600/10'
|
||||
: 'border-green-500 text-green-600 hover:bg-green-50 hover:dark:bg-green-600/10'
|
||||
}`}
|
||||
>
|
||||
{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 dark:hover:bg-red-600/10 rounded-full px-4 py-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<UserCog className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<UserCog className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Staff Found
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || roleFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'No staff members yet'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pending-invitations">
|
||||
<PendingInvitationsTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateStaffDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={fetchStaff}
|
||||
/>
|
||||
|
||||
<InviteStaffDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
onSuccess={() => {
|
||||
// Optionally refresh invitations table
|
||||
setActiveTab('pending-invitations');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
@@ -30,10 +31,25 @@ import {
|
||||
Loader2,
|
||||
Calendar,
|
||||
Edit,
|
||||
XCircle
|
||||
XCircle,
|
||||
Download,
|
||||
FileDown,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Copy
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../components/ui/dropdown-menu';
|
||||
|
||||
const AdminSubscriptions = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [subscriptions, setSubscriptions] = useState([]);
|
||||
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
|
||||
const [plans, setPlans] = useState([]);
|
||||
@@ -42,6 +58,8 @@ const AdminSubscriptions = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [planFilter, setPlanFilter] = useState('all');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [expandedRows, setExpandedRows] = useState(new Set());
|
||||
|
||||
// Edit subscription dialog state
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
@@ -118,6 +136,62 @@ const AdminSubscriptions = () => {
|
||||
const handleSaveSubscription = async () => {
|
||||
if (!selectedSubscription) return;
|
||||
|
||||
// Check if status is changing
|
||||
const statusChanged = editFormData.status !== selectedSubscription.status;
|
||||
|
||||
if (statusChanged) {
|
||||
// Get status change consequences
|
||||
let warningMessage = '';
|
||||
let confirmText = '';
|
||||
|
||||
if (editFormData.status === 'cancelled') {
|
||||
warningMessage = `⚠️ CRITICAL: Cancelling this subscription will:
|
||||
|
||||
• Set the user's status to INACTIVE
|
||||
• Remove their member access immediately
|
||||
• Stop all future billing
|
||||
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
||||
|
||||
Current Status: ${selectedSubscription.status.toUpperCase()}
|
||||
New Status: CANCELLED
|
||||
|
||||
Are you absolutely sure you want to proceed?`;
|
||||
confirmText = 'Yes, Cancel Subscription';
|
||||
} else if (editFormData.status === 'expired') {
|
||||
warningMessage = `⚠️ WARNING: Setting this subscription to EXPIRED will:
|
||||
|
||||
• Set the user's status to INACTIVE
|
||||
• Remove their member access
|
||||
• Mark the subscription as ended
|
||||
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
||||
|
||||
Current Status: ${selectedSubscription.status.toUpperCase()}
|
||||
New Status: EXPIRED
|
||||
|
||||
Are you sure you want to proceed?`;
|
||||
confirmText = 'Yes, Mark as Expired';
|
||||
} else if (editFormData.status === 'active') {
|
||||
warningMessage = `✓ Activating this subscription will:
|
||||
|
||||
• Set the user's status to ACTIVE
|
||||
• Grant full member access
|
||||
• Resume billing if applicable
|
||||
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
||||
|
||||
Current Status: ${selectedSubscription.status.toUpperCase()}
|
||||
New Status: ACTIVE
|
||||
|
||||
Proceed with activation?`;
|
||||
confirmText = 'Yes, Activate Subscription';
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
const confirmed = window.confirm(warningMessage);
|
||||
if (!confirmed) {
|
||||
return; // User cancelled
|
||||
}
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await api.put(`/admin/subscriptions/${selectedSubscription.id}`, {
|
||||
@@ -151,6 +225,38 @@ const AdminSubscriptions = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (exportType) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const params = exportType === 'current' ? {
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
plan_id: planFilter !== 'all' ? planFilter : undefined,
|
||||
search: searchQuery || undefined
|
||||
} : {};
|
||||
|
||||
const response = await api.get('/admin/subscriptions/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `subscriptions_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Subscriptions exported successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to export subscriptions:', error);
|
||||
toast.error('Failed to export subscriptions');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (cents) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
@@ -164,6 +270,38 @@ const AdminSubscriptions = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRowExpansion = (subscriptionId) => {
|
||||
setExpandedRows((prev) => {
|
||||
const newExpanded = new Set(prev);
|
||||
if (newExpanded.has(subscriptionId)) {
|
||||
newExpanded.delete(subscriptionId);
|
||||
} else {
|
||||
newExpanded.add(subscriptionId);
|
||||
}
|
||||
return newExpanded;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text, label) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status) => {
|
||||
const variants = {
|
||||
active: 'default',
|
||||
@@ -176,7 +314,7 @@ const AdminSubscriptions = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-[#664fa3]" />
|
||||
<Loader2 className="h-12 w-12 animate-spin text-brand-purple " />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -185,93 +323,93 @@ const AdminSubscriptions = () => {
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Management
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage all member subscriptions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid md:grid-cols-4 gap-6">
|
||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Total Subscriptions
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-[#422268] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{stats.total || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#DDD8EB]/20 rounded-full">
|
||||
<CreditCard className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
|
||||
<CreditCard className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Active Members
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-[#81B29A] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-3xl font-bold text-[var(--green-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{stats.active || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#81B29A]/10 rounded-full">
|
||||
<TrendingUp className="h-6 w-6 text-[#81B29A]" />
|
||||
<div className="p-3 bg-[var(--green-light)]/10 rounded-full">
|
||||
<TrendingUp className="h-6 w-6 text-[var(--green-light)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Total Revenue
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-[#422268] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(stats.total_revenue || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#DDD8EB]/20 rounded-full">
|
||||
<DollarSign className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
|
||||
<DollarSign className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Total Donations
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-[#ff9e77] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-3xl font-bold text-[var(--orange-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(stats.total_donations || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#ff9e77]/10 rounded-full">
|
||||
<Heart className="h-6 w-6 text-[#ff9e77]" />
|
||||
<div className="p-3 bg-[var(--orange-light)]/10 rounded-full">
|
||||
<Heart className="h-6 w-6 text-[var(--orange-light)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Bar */}
|
||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{/* Search */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,7 +417,7 @@ const AdminSubscriptions = () => {
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -294,7 +432,7 @@ const AdminSubscriptions = () => {
|
||||
{/* Plan Filter */}
|
||||
<div>
|
||||
<Select value={planFilter} onValueChange={setPlanFilter}>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="All Plans" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -307,26 +445,59 @@ const AdminSubscriptions = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
|
||||
</div>
|
||||
|
||||
{/* Export Dropdown */}
|
||||
{hasPermission('subscriptions.export') && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={exporting}
|
||||
className="btn-green py-2 flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-background rounded-xl border-2 border-[var(--neutral-800)] shadow-lg">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('all')}
|
||||
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-brand-purple " />
|
||||
<span className="text-[var(--purple-ink)]">Export All Subscriptions</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('current')}
|
||||
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-brand-purple " />
|
||||
<span className="text-[var(--purple-ink)]">Export Current View</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Subscriptions Table */}
|
||||
<Card className="bg-white rounded-2xl border-2 border-[#ddd8eb] overflow-hidden">
|
||||
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
|
||||
{/* Mobile Card View */}
|
||||
<div className="md:hidden p-4 space-y-4">
|
||||
{filteredSubscriptions.length > 0 ? (
|
||||
filteredSubscriptions.map((sub) => (
|
||||
<Card key={sub.id} className="p-4 border border-[#ddd8eb] bg-[#f9f5ff]/30">
|
||||
<Card key={sub.id} className="p-4 border border-[var(--neutral-800)] bg-[var(--lavender-400)]/30">
|
||||
<div className="space-y-3">
|
||||
{/* Member Info */}
|
||||
<div className="flex justify-between items-start border-b border-[#ddd8eb] pb-3">
|
||||
<div className="flex justify-between items-start border-b border-[var(--neutral-800)] pb-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{sub.user.first_name} {sub.user.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{sub.user.email}
|
||||
</p>
|
||||
</div>
|
||||
@@ -336,13 +507,13 @@ const AdminSubscriptions = () => {
|
||||
{/* Plan & Period */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-[#664fa3] mb-1">Plan</p>
|
||||
<p className="font-medium text-[#422268]">{sub.plan.name}</p>
|
||||
<p className="text-xs text-[#664fa3]">{sub.plan.billing_cycle}</p>
|
||||
<p className="text-xs text-brand-purple mb-1">Plan</p>
|
||||
<p className="font-medium text-[var(--purple-ink)]">{sub.plan.name}</p>
|
||||
<p className="text-xs text-brand-purple ">{sub.plan.billing_cycle}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[#664fa3] mb-1">Period</p>
|
||||
<p className="text-[#422268]">
|
||||
<p className="text-xs text-brand-purple mb-1">Period</p>
|
||||
<p className="text-[var(--purple-ink)]">
|
||||
{new Date(sub.current_period_start).toLocaleDateString()} -
|
||||
{new Date(sub.current_period_end).toLocaleDateString()}
|
||||
</p>
|
||||
@@ -350,22 +521,22 @@ const AdminSubscriptions = () => {
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="grid grid-cols-3 gap-2 text-sm bg-white/50 p-3 rounded">
|
||||
<div className="grid grid-cols-3 gap-2 text-sm bg-background/50 p-3 rounded">
|
||||
<div>
|
||||
<p className="text-xs text-[#664fa3] mb-1">Base Fee</p>
|
||||
<p className="font-medium text-[#422268]">
|
||||
<p className="text-xs text-brand-purple mb-1">Base Fee</p>
|
||||
<p className="font-medium text-[var(--purple-ink)]">
|
||||
${(sub.base_fee_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[#664fa3] mb-1">Donation</p>
|
||||
<p className="font-medium text-[#422268]">
|
||||
<p className="text-xs text-brand-purple mb-1">Donation</p>
|
||||
<p className="font-medium text-[var(--purple-ink)]">
|
||||
${(sub.donation_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[#664fa3] mb-1">Total</p>
|
||||
<p className="font-semibold text-[#422268]">
|
||||
<p className="text-xs text-brand-purple mb-1">Total</p>
|
||||
<p className="font-semibold text-[var(--purple-ink)]">
|
||||
${(sub.total_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -373,21 +544,23 @@ const AdminSubscriptions = () => {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{hasPermission('subscriptions.edit') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(sub)}
|
||||
className="flex-1 text-[#664fa3] hover:bg-[#DDD8EB]"
|
||||
className="flex-1 text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
{sub.status === 'active' && (
|
||||
)}
|
||||
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="outline-destructive"
|
||||
onClick={() => handleCancelSubscription(sub.id)}
|
||||
className="flex-1 text-red-600 hover:bg-red-50"
|
||||
className="flex-1 "
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
@@ -398,7 +571,7 @@ const AdminSubscriptions = () => {
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="p-12 text-center text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="p-12 text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No subscriptions found
|
||||
</div>
|
||||
)}
|
||||
@@ -408,50 +581,56 @@ const AdminSubscriptions = () => {
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#DDD8EB]/20 border-b border-[#ddd8eb]">
|
||||
<th className="text-left p-4 text-[#422268] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<tr className="bg-[var(--neutral-800)]/20 border-b border-[var(--neutral-800)]">
|
||||
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Member
|
||||
</th>
|
||||
<th className="text-left p-4 text-[#422268] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Plan
|
||||
</th>
|
||||
<th className="text-left p-4 text-[#422268] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left p-4 text-[#422268] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Period
|
||||
</th>
|
||||
<th className="text-right p-4 text-[#422268] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<th className="text-right p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Base Fee
|
||||
</th>
|
||||
<th className="text-right p-4 text-[#422268] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<th className="text-right p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Donation
|
||||
</th>
|
||||
<th className="text-right p-4 text-[#422268] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<th className="text-right p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Total
|
||||
</th>
|
||||
<th className="text-center p-4 text-[#422268] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<th className="text-center p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Details
|
||||
</th>
|
||||
<th className="text-center p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSubscriptions.length > 0 ? (
|
||||
filteredSubscriptions.map((sub) => (
|
||||
<tr key={sub.id} className="border-b border-[#ddd8eb] hover:bg-[#f9f5ff] transition-colors">
|
||||
filteredSubscriptions.map((sub) => {
|
||||
const isExpanded = expandedRows.has(sub.id);
|
||||
return (
|
||||
<React.Fragment key={sub.id}>
|
||||
<tr className="border-b border-[var(--neutral-800)] hover:bg-[var(--lavender-400)] transition-colors">
|
||||
<td className="p-4">
|
||||
<div className="font-medium text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{sub.user.first_name} {sub.user.last_name}
|
||||
</div>
|
||||
<div className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{sub.user.email}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{sub.plan.name}
|
||||
</div>
|
||||
<div className="text-xs text-[#664fa3]">
|
||||
<div className="text-xs text-brand-purple ">
|
||||
{sub.plan.billing_cycle}
|
||||
</div>
|
||||
</td>
|
||||
@@ -461,36 +640,48 @@ const AdminSubscriptions = () => {
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div>{formatDate(sub.start_date)}</div>
|
||||
<div className="text-xs text-[#664fa3]">to {formatDate(sub.end_date)}</div>
|
||||
<div className="text-xs text-brand-purple ">to {formatDate(sub.end_date)}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-right text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<td className="p-4 text-right text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(sub.base_subscription_cents || 0)}
|
||||
</td>
|
||||
<td className="p-4 text-right text-[#ff9e77]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<td className="p-4 text-right text-[var(--orange-light)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(sub.donation_cents || 0)}
|
||||
</td>
|
||||
<td className="p-4 text-right font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<td className="p-4 text-right font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(sub.amount_paid_cents || 0)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleRowExpansion(sub.id)}
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{hasPermission('subscriptions.edit') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(sub)}
|
||||
className="text-[#664fa3] hover:bg-[#DDD8EB]"
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{sub.status === 'active' && (
|
||||
)}
|
||||
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="outline-destructive"
|
||||
onClick={() => handleCancelSubscription(sub.id)}
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
className=""
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -498,10 +689,162 @@ const AdminSubscriptions = () => {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
{/* Expandable Details Row */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-[var(--lavender-400)]/30">
|
||||
<td colSpan="9" className="p-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Transaction Details
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Payment Information */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Payment Information
|
||||
</h5>
|
||||
<div className="space-y-2 text-sm">
|
||||
{sub.payment_completed_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Payment Date:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium">{formatDateTime(sub.payment_completed_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
{sub.payment_method && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Payment Method:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium capitalize">{sub.payment_method}</span>
|
||||
</div>
|
||||
)}
|
||||
{sub.card_brand && sub.card_last4 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Card:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium">{sub.card_brand} ****{sub.card_last4}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe Transaction IDs */}
|
||||
<div className="space-y-3">
|
||||
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Info className="h-4 w-4" />
|
||||
Stripe Transaction IDs
|
||||
</h5>
|
||||
<div className="space-y-2 text-sm">
|
||||
{sub.stripe_payment_intent_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Payment Intent:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_payment_intent_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_payment_intent_id, 'Payment Intent ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_charge_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Charge ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_charge_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_charge_id, 'Charge ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_subscription_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Subscription ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_subscription_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_subscription_id, 'Subscription ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_invoice_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Invoice ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_invoice_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_invoice_id, 'Invoice ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_customer_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Customer ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_customer_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_customer_id, 'Customer ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_receipt_url && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Receipt:</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(sub.stripe_receipt_url, '_blank')}
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View Receipt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="8" className="p-12 text-center text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<td colSpan="9" className="p-12 text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No subscriptions found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -513,12 +856,12 @@ const AdminSubscriptions = () => {
|
||||
|
||||
{/* Edit Subscription Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-white rounded-2xl">
|
||||
<DialogContent className="sm:max-w-[500px] bg-background rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Edit Subscription
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Update subscription status or end date for {selectedSubscription?.user.first_name} {selectedSubscription?.user.last_name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -526,14 +869,14 @@ const AdminSubscriptions = () => {
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="status" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Status
|
||||
</Label>
|
||||
<Select
|
||||
value={editFormData.status}
|
||||
onValueChange={(value) => setEditFormData({ ...editFormData, status: value })}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -542,21 +885,73 @@ const AdminSubscriptions = () => {
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Warning Box - Show when status is different */}
|
||||
{selectedSubscription && editFormData.status !== selectedSubscription.status && (
|
||||
<div className={`mt-3 p-4 rounded-xl border-2 ${editFormData.status === 'cancelled'
|
||||
? 'bg-red-50 border-red-300'
|
||||
: editFormData.status === 'expired'
|
||||
? 'bg-orange-50 border-orange-300'
|
||||
: 'bg-green-50 border-green-300'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{editFormData.status === 'cancelled' || editFormData.status === 'expired' ? (
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{editFormData.status === 'cancelled' && 'Critical: This will cancel the subscription'}
|
||||
{editFormData.status === 'expired' && 'Warning: This will mark subscription as expired'}
|
||||
{editFormData.status === 'active' && 'This will activate the subscription'}
|
||||
</p>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{editFormData.status === 'cancelled' && (
|
||||
<>
|
||||
<li>User status will be set to INACTIVE</li>
|
||||
<li>Member access will be removed immediately</li>
|
||||
<li>All future billing will be stopped</li>
|
||||
</>
|
||||
)}
|
||||
{editFormData.status === 'expired' && (
|
||||
<>
|
||||
<li>User status will be set to INACTIVE</li>
|
||||
<li>Member access will be removed</li>
|
||||
<li>Subscription will be marked as ended</li>
|
||||
</>
|
||||
)}
|
||||
{editFormData.status === 'active' && (
|
||||
<>
|
||||
<li>User status will be set to ACTIVE</li>
|
||||
<li>Full member access will be granted</li>
|
||||
<li>Billing will resume if applicable</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<p className="text-xs mt-2 font-medium">
|
||||
Current: <span className="font-bold">{selectedSubscription.status.toUpperCase()}</span> →
|
||||
New: <span className="font-bold">{editFormData.status.toUpperCase()}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* End Date */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="end_date" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="end_date" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
End Date
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||
<Input
|
||||
id="end_date"
|
||||
type="date"
|
||||
value={editFormData.end_date}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, end_date: e.target.value })}
|
||||
className="pl-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -567,7 +962,7 @@ const AdminSubscriptions = () => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditDialogOpen(false)}
|
||||
className="rounded-full border-2 border-[#ddd8eb]"
|
||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
@@ -576,7 +971,7 @@ const AdminSubscriptions = () => {
|
||||
type="button"
|
||||
onClick={handleSaveSubscription}
|
||||
disabled={isUpdating}
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087] rounded-full"
|
||||
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)] rounded-full"
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } 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, Lock, AlertTriangle } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '../../components/ui/avatar';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera, Upload, Trash2, Shield } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import ChangeRoleDialog from '../../components/ChangeRoleDialog';
|
||||
import TransactionHistory from '../../components/TransactionHistory';
|
||||
|
||||
const AdminUserView = () => {
|
||||
const { userId } = useParams();
|
||||
@@ -14,11 +19,65 @@ const AdminUserView = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
|
||||
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
|
||||
const [transactionsLoading, setTransactionsLoading] = useState(true);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
|
||||
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
|
||||
const [memberSince, setMemberSince] = useState('');
|
||||
const [memberSinceSaving, setMemberSinceSaving] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
const [changeRoleDialogOpen, setChangeRoleDialogOpen] = useState(false);
|
||||
|
||||
const formatLocalDateInputValue = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatDateInputValue = (value) => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
return formatLocalDateInputValue(parsed);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '';
|
||||
return formatLocalDateInputValue(parsed);
|
||||
};
|
||||
|
||||
const formatDateDisplayValue = (value) => {
|
||||
if (!value) return 'N/A';
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
return new Date(year, month - 1, day).toLocaleDateString();
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return 'N/A';
|
||||
return parsed.toLocaleDateString();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchUserProfile();
|
||||
fetchTransactions();
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setMemberSince(formatDateInputValue(user.member_since));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
const response = await api.get(`/admin/users/${userId}`);
|
||||
@@ -31,17 +90,110 @@ const AdminUserView = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
const confirmed = window.confirm(
|
||||
`Reset password for ${user.first_name} ${user.last_name}?\n\n` +
|
||||
`A temporary password will be emailed to ${user.email}.\n` +
|
||||
`They will be required to change it on next login.`
|
||||
);
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
setTransactionsLoading(true);
|
||||
const response = await api.get(`/admin/users/${userId}/transactions`);
|
||||
setTransactions(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transactions:', error);
|
||||
} finally {
|
||||
setTransactionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!confirmed) return;
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await api.get('/config');
|
||||
setMaxFileSizeMB(response.data.max_file_size_mb);
|
||||
setMaxFileSizeBytes(response.data.max_file_size_bytes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config, using defaults:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSizeBytes) {
|
||||
toast.error(`File size must be less than ${maxFileSizeMB}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingPhoto(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post(`/admin/users/${userId}/upload-photo`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
// Update user state with new photo URL
|
||||
setUser(prev => ({
|
||||
...prev,
|
||||
profile_photo_url: response.data.profile_photo_url
|
||||
}));
|
||||
|
||||
toast.success('Profile photo updated successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to upload photo');
|
||||
} finally {
|
||||
setUploadingPhoto(false);
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoDelete = async () => {
|
||||
if (!user?.profile_photo_url) return;
|
||||
|
||||
setUploadingPhoto(true);
|
||||
try {
|
||||
await api.delete(`/admin/users/${userId}/delete-photo`);
|
||||
|
||||
// Update user state to remove photo URL
|
||||
setUser(prev => ({
|
||||
...prev,
|
||||
profile_photo_url: null
|
||||
}));
|
||||
|
||||
toast.success('Profile photo deleted successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete photo');
|
||||
} finally {
|
||||
setUploadingPhoto(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPasswordRequest = () => {
|
||||
setPendingAction({ type: 'reset_password' });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleResendVerificationRequest = () => {
|
||||
setPendingAction({ type: 'resend_verification' });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
const { type } = pendingAction;
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
if (type === 'reset_password') {
|
||||
setResetPasswordLoading(true);
|
||||
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/reset-password`, {
|
||||
force_change: true
|
||||
@@ -52,18 +204,10 @@ const AdminUserView = () => {
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResetPasswordLoading(false);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
const confirmed = window.confirm(
|
||||
`Resend verification email to ${user.email}?`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
} else if (type === 'resend_verification') {
|
||||
setResendVerificationLoading(true);
|
||||
|
||||
try {
|
||||
await api.post(`/admin/users/${userId}/resend-verification`);
|
||||
toast.success(`Verification email sent to ${user.email}`);
|
||||
@@ -74,12 +218,71 @@ const AdminUserView = () => {
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResendVerificationLoading(false);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemberSinceSave = async () => {
|
||||
if (!user) return;
|
||||
setMemberSinceSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
member_since: memberSince ? memberSince : null
|
||||
};
|
||||
const response = await api.put(`/admin/users/${userId}`, payload);
|
||||
setUser(prev => ({
|
||||
...prev,
|
||||
...(response?.data || {}),
|
||||
member_since: payload.member_since
|
||||
}));
|
||||
toast.success('Member since updated successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update member since');
|
||||
} finally {
|
||||
setMemberSinceSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionMessage = () => {
|
||||
if (!pendingAction || !user) return {};
|
||||
|
||||
const { type } = pendingAction;
|
||||
const userName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
if (type === 'reset_password') {
|
||||
return {
|
||||
title: 'Reset Password?',
|
||||
description: `This will send a temporary password to ${user.email}. ${userName} will be required to change it on their next login.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Reset Password',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'resend_verification') {
|
||||
return {
|
||||
title: 'Resend Verification Email?',
|
||||
description: `This will send a new verification email to ${user.email}. ${userName} will need to click the link to verify their email address.`,
|
||||
variant: 'info',
|
||||
confirmText: 'Yes, Resend Email',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleRoleChanged = () => {
|
||||
// Refresh user data after role change
|
||||
fetchUserProfile();
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!user) return null;
|
||||
|
||||
const joinedDate = user.member_since || user.created_at;
|
||||
const memberSinceBaseline = formatDateInputValue(user.member_since);
|
||||
const memberSinceHasChanges = memberSince !== memberSinceBaseline;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Back Button */}
|
||||
@@ -93,17 +296,20 @@ const AdminUserView = () => {
|
||||
</Button>
|
||||
|
||||
{/* User Profile Header */}
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
||||
<div className="flex items-start gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="h-24 w-24 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-3xl">
|
||||
<Avatar className="h-24 w-24 border-4 border-[var(--neutral-800)]">
|
||||
<AvatarImage src={user.profile_photo_url} alt={`${user.first_name} ${user.last_name}`} />
|
||||
<AvatarFallback className="bg-[var(--neutral-800)] text-[var(--purple-ink)] font-semibold text-3xl">
|
||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||
</div>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h1>
|
||||
{/* Status & Role Badges */}
|
||||
@@ -112,7 +318,7 @@ const AdminUserView = () => {
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="grid md:grid-cols-2 gap-4 text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
<span>{user.email}</span>
|
||||
@@ -127,7 +333,7 @@ const AdminUserView = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Joined {new Date(user.created_at).toLocaleDateString()}</span>
|
||||
<span>Joined {formatDateDisplayValue(joinedDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,34 +341,74 @@ const AdminUserView = () => {
|
||||
</Card>
|
||||
|
||||
{/* Admin Actions */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||
<h2 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
||||
<h2 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Admin Actions
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
onClick={handleResetPasswordRequest}
|
||||
disabled={resetPasswordLoading}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
>
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
{resetPasswordLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setChangeRoleDialogOpen(true)}
|
||||
variant="outline"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-4 py-2"
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Change Role
|
||||
</Button>
|
||||
|
||||
{!user.email_verified && (
|
||||
<Button
|
||||
onClick={handleResendVerification}
|
||||
onClick={handleResendVerificationRequest}
|
||||
disabled={resendVerificationLoading}
|
||||
variant="outline"
|
||||
className="border-2 border-[#ff9e77] text-[#ff9e77] hover:bg-[#FFF3E0] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
className="border-2 border-[var(--orange-light)] text-[var(--orange-light)] hover:bg-[#FFF3E0] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
{resendVerificationLoading ? 'Sending...' : 'Resend Verification Email'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-[#664fa3] ml-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{/* Profile Photo Management */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingPhoto}
|
||||
variant="outline"
|
||||
className="border-2 border-[var(--green-light)] text-[var(--green-light)] hover:bg-[var(--green-bg)] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
|
||||
</Button>
|
||||
|
||||
{user.profile_photo_url && (
|
||||
<Button
|
||||
onClick={handlePhotoDelete}
|
||||
disabled={uploadingPhoto}
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-4 py-2 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Photo
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-brand-purple ml-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>User will receive a temporary password via email</span>
|
||||
</div>
|
||||
@@ -170,28 +416,49 @@ const AdminUserView = () => {
|
||||
</Card>
|
||||
|
||||
{/* Additional Details */}
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Additional Information
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</label>
|
||||
<p className="text-[#422268] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.address}</p>
|
||||
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</label>
|
||||
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.address}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</label>
|
||||
<p className="text-[#422268] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(user.date_of_birth).toLocaleDateString()}
|
||||
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</label>
|
||||
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{formatDateDisplayValue(user.date_of_birth)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</label>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={memberSince}
|
||||
onChange={(e) => setMemberSince(e.target.value)}
|
||||
className="max-w-[200px] border-[var(--neutral-800)]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleMemberSinceSave}
|
||||
disabled={memberSinceSaving || !memberSinceHasChanges}
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
|
||||
>
|
||||
{memberSinceSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.partner_first_name && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Partner</label>
|
||||
<p className="text-[#422268] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Partner</label>
|
||||
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.partner_first_name} {user.partner_last_name}
|
||||
</p>
|
||||
</div>
|
||||
@@ -199,14 +466,14 @@ const AdminUserView = () => {
|
||||
|
||||
{user.referred_by_member_name && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Referred By</label>
|
||||
<p className="text-[#422268] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.referred_by_member_name}</p>
|
||||
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Referred By</label>
|
||||
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Lead Sources</label>
|
||||
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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>
|
||||
@@ -217,16 +484,34 @@ const AdminUserView = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Subscription Info (if applicable) */}
|
||||
{user.role === 'member' && (
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] mt-8">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Information
|
||||
</h2>
|
||||
{/* TODO: Fetch and display subscription data */}
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Subscription details coming soon...</p>
|
||||
</Card>
|
||||
)}
|
||||
{/* Transaction History */}
|
||||
<div className="mt-8">
|
||||
<TransactionHistory
|
||||
subscriptions={transactions.subscriptions}
|
||||
donations={transactions.donations}
|
||||
totalSubscriptionCents={transactions.total_subscription_amount_cents}
|
||||
totalDonationCents={transactions.total_donation_amount_cents}
|
||||
loading={transactionsLoading}
|
||||
isAdmin={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Admin Action Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onConfirm={confirmAction}
|
||||
loading={resetPasswordLoading || resendVerificationLoading}
|
||||
{...getActionMessage()}
|
||||
/>
|
||||
|
||||
{/* Change Role Dialog */}
|
||||
<ChangeRoleDialog
|
||||
open={changeRoleDialogOpen}
|
||||
onClose={() => setChangeRoleDialogOpen(false)}
|
||||
user={user}
|
||||
onSuccess={handleRoleChanged}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
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, Mail, Eye } 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 [resendingUserId, setResendingUserId] = useState(null);
|
||||
|
||||
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-orange-100 text-orange-700' },
|
||||
pending_approval: { label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' }
|
||||
};
|
||||
|
||||
const statusConfig = config[status] || config.inactive;
|
||||
return (
|
||||
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAdminResendVerification = async (userId, userEmail) => {
|
||||
const confirmed = window.confirm(
|
||||
`Resend verification email to ${userEmail}?`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setResendingUserId(userId);
|
||||
try {
|
||||
await api.post(`/admin/users/${userId}/resend-verification`);
|
||||
toast.success(`Verification email sent to ${userEmail}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResendingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage all registered users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] 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-[#664fa3]" />
|
||||
<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-[#ddd8eb] focus:border-[#664fa3]"
|
||||
data-testid="search-users-input"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" 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-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-[#ddd8eb] 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 text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
{getStatusBadge(user.status)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<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 className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#664fa3] hover:text-[#422268]"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
|
||||
{!user.email_verified && (
|
||||
<Button
|
||||
onClick={() => handleAdminResendVerification(user.id, user.email)}
|
||||
disabled={resendingUserId === user.id}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3]"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
{resendingUserId === user.id ? 'Sending...' : 'Resend Verification'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<Users className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Users Found
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'No users registered yet'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsers;
|
||||
626
src/pages/admin/AdminValidations.js
Normal file
626
src/pages/admin/AdminValidations.js
Normal file
@@ -0,0 +1,626 @@
|
||||
import React, { useEffect, useState } 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 { 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, X } from 'lucide-react';
|
||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import RejectionDialog from '../../components/RejectionDialog';
|
||||
|
||||
const AdminValidations = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
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);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false);
|
||||
const [userToReject, setUserToReject] = 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_validation', 'pre_validated', 'payment_pending', 'rejected'].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 handleValidateRequest = (user) => {
|
||||
setPendingAction({ type: 'validate', user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleBypassAndValidateRequest = (user) => {
|
||||
setPendingAction({ type: 'bypass_and_validate', user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
const { type, user } = pendingAction;
|
||||
setActionLoading(user.id);
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
try {
|
||||
if (type === 'validate') {
|
||||
await api.put(`/admin/users/${user.id}/validate`);
|
||||
toast.success('User validated! Payment email sent.');
|
||||
} else if (type === 'bypass_and_validate') {
|
||||
await api.put(`/admin/users/${user.id}/validate?bypass_email_verification=true`);
|
||||
toast.success('User email verified and validated! Payment email sent.');
|
||||
}
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to validate user');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionMessage = () => {
|
||||
if (!pendingAction) return {};
|
||||
|
||||
const { type, user } = pendingAction;
|
||||
const userName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
if (type === 'validate') {
|
||||
return {
|
||||
title: 'Validate User?',
|
||||
description: `This will validate ${userName} and send them a payment link email. They will be able to complete payment and become an active member.`,
|
||||
variant: 'success',
|
||||
confirmText: 'Yes, Validate User',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'bypass_and_validate') {
|
||||
return {
|
||||
title: 'Bypass Email & Validate User?',
|
||||
description: `This will bypass email verification for ${userName} and validate them immediately. A payment link email will be sent. Use this only if you've confirmed their email through other means.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Bypass & Validate',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleActivatePayment = (user) => {
|
||||
setSelectedUserForPayment(user);
|
||||
setPaymentDialogOpen(true);
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = () => {
|
||||
fetchPendingUsers(); // Refresh list
|
||||
};
|
||||
|
||||
const handleRejectUser = (user) => {
|
||||
setUserToReject(user);
|
||||
setRejectionDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmRejection = async (reason) => {
|
||||
if (!userToReject) return;
|
||||
|
||||
setActionLoading(userToReject.id);
|
||||
try {
|
||||
await api.post(`/admin/users/${userToReject.id}/reject`, { reason });
|
||||
toast.success(`${userToReject.first_name} ${userToReject.last_name} has been rejected`);
|
||||
fetchPendingUsers();
|
||||
setRejectionDialogOpen(false);
|
||||
setUserToReject(null);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to reject user');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivateUser = async (user) => {
|
||||
setActionLoading(user.id);
|
||||
try {
|
||||
await api.put(`/admin/users/${user.id}/status`, {
|
||||
status: 'pending_validation'
|
||||
});
|
||||
toast.success(`${user.first_name} ${user.last_name} has been reactivated and moved to pending validation`);
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to reactivate user');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { label: 'Pre-Validated', className: 'bg-[var(--green-light)] text-white' },
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
rejected: { label: 'Rejected', className: 'bg-red-100 text-red-700' }
|
||||
};
|
||||
|
||||
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 text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Validation Queue
|
||||
</h1>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and validate pending membership applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Pending</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Awaiting Email</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pending_email').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pending_validation').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pre_validated').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'payment_pending').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-red-600 mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Rejected</p>
|
||||
<p className="text-3xl font-semibold text-red-800" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'rejected').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Filter Card */}
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] 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-brand-purple " />
|
||||
<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-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="pending_email">Awaiting Email</SelectItem>
|
||||
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading pending applications...</p>
|
||||
</div>
|
||||
) : filteredUsers.length > 0 ? (
|
||||
<>
|
||||
<Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
|
||||
onClick={() => handleSort('first_name')}
|
||||
>
|
||||
Name {renderSortIcon('first_name')}
|
||||
</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Phone</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
Status {renderSortIcon('status')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-[var(--neutral-800)]/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 === 'rejected' ? (
|
||||
<Button
|
||||
onClick={() => handleReactivateUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]"
|
||||
>
|
||||
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'}
|
||||
</Button>
|
||||
) : user.status === 'pending_email' ? (
|
||||
<>
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleBypassAndValidateRequest(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : user.status === 'payment_pending' ? (
|
||||
<>
|
||||
{hasPermission('subscriptions.activate') && (
|
||||
<Button
|
||||
onClick={() => handleActivatePayment(user)}
|
||||
size="sm"
|
||||
className="btn-light-lavender"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Activate Payment
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleValidateRequest(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</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-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>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-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
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-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Pending Validations
|
||||
</h3>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{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}
|
||||
/>
|
||||
|
||||
{/* Validation Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onConfirm={confirmAction}
|
||||
loading={actionLoading !== null}
|
||||
{...getActionMessage()}
|
||||
/>
|
||||
|
||||
{/* Rejection Dialog */}
|
||||
<RejectionDialog
|
||||
open={rejectionDialogOpen}
|
||||
onOpenChange={setRejectionDialogOpen}
|
||||
onConfirm={confirmRejection}
|
||||
user={userToReject}
|
||||
loading={actionLoading !== null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminValidations;
|
||||
@@ -51,10 +51,10 @@ export default function Bylaws() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading bylaws...
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,38 +63,38 @@ export default function Bylaws() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
LOAF Bylaws
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review the official governing bylaws and policies of the LOAF community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Bylaws */}
|
||||
{currentBylaws ? (
|
||||
<Card className="p-8 bg-white rounded-2xl border-2 border-[#664fa3] mb-6">
|
||||
<Card className="p-8 bg-background rounded-2xl border-2 border-brand-purple mb-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="bg-gradient-to-br from-[#664fa3] to-[#422268] p-4 rounded-xl">
|
||||
<div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl">
|
||||
<Scale className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{currentBylaws.title}
|
||||
</h2>
|
||||
<Badge className="bg-[#81B29A] text-white">
|
||||
<Badge className="bg-[var(--green-light)] text-white">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Current Version
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[#664fa3] mb-4">
|
||||
<div className="flex items-center gap-4 text-brand-purple mb-4">
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Version: <strong>{currentBylaws.version}</strong>
|
||||
</span>
|
||||
@@ -106,7 +106,7 @@ export default function Bylaws() {
|
||||
<Button
|
||||
onClick={() => window.open(currentBylaws.document_url, '_blank')}
|
||||
size="lg"
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
View Current Bylaws
|
||||
@@ -115,9 +115,9 @@ export default function Bylaws() {
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-12 text-center bg-white rounded-2xl border border-[#ddd8eb] mb-6">
|
||||
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Card className="p-12 text-center bg-background rounded-2xl border border-[var(--neutral-800)] mb-6">
|
||||
<Scale className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<p className="text-brand-purple text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No current bylaws document available
|
||||
</p>
|
||||
</Card>
|
||||
@@ -129,7 +129,7 @@ export default function Bylaws() {
|
||||
<Button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
variant="outline"
|
||||
className="w-full border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9] rounded-full flex items-center justify-center gap-2"
|
||||
className="w-full border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
{showHistory ? 'Hide' : 'View'} Version History ({history.length - 1} previous {history.length - 1 === 1 ? 'version' : 'versions'})
|
||||
@@ -140,17 +140,17 @@ export default function Bylaws() {
|
||||
{/* Version History */}
|
||||
{showHistory && history.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Previous Versions
|
||||
</h3>
|
||||
{history.filter(b => !b.is_current).map(bylaws => (
|
||||
<Card key={bylaws.id} className="p-6 bg-[#f9f7fc] rounded-xl border border-[#ddd8eb]">
|
||||
<Card key={bylaws.id} className="p-6 bg-[var(--lavender-600)] rounded-xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h4 className="text-lg font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{bylaws.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-sm text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-sm text-brand-purple ">
|
||||
<span>Version {bylaws.version}</span>
|
||||
<span>•</span>
|
||||
<span>Effective {formatDate(bylaws.effective_date)}</span>
|
||||
@@ -160,7 +160,7 @@ export default function Bylaws() {
|
||||
onClick={() => window.open(bylaws.document_url, '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full flex items-center gap-2"
|
||||
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View
|
||||
@@ -172,14 +172,14 @@ export default function Bylaws() {
|
||||
)}
|
||||
|
||||
{/* Information Card */}
|
||||
<Card className="mt-8 p-6 bg-[#f9f7fc] border border-[#ddd8eb]">
|
||||
<Card className="mt-8 p-6 bg-[var(--lavender-600)] border border-[var(--neutral-800)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<Scale className="h-5 w-5 text-[#664fa3] mt-1" />
|
||||
<Scale className="h-5 w-5 text-brand-purple mt-1" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
About LOAF Bylaws
|
||||
</h4>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The bylaws serve as the governing document for LOAF, outlining the organization's structure,
|
||||
membership requirements, officer responsibilities, and operational procedures. All members are
|
||||
encouraged to familiarize themselves with these guidelines.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user