26 Commits

Author SHA1 Message Date
kayela
4423576fa2 Merge branch 'theme-provider' into dev 2026-01-29 00:01:43 -06:00
kayela
a77fbc47e3 styling improvements 2026-01-28 19:08:13 -06:00
kayela
d638afcdb2 add column for email expiry date
Members > Invite member says invite Staff in dialog
 resend email button
 Update form member form to say member and not staff
 review application function
 manual payment functionality
 basic implementation of theme
 actions dropdown
2026-01-28 18:59:19 -06:00
kayela
a247ac5219 feat: added theme success and warning colors
fix: invite member dialog box
feat: email expiry date column
feat: resend email verification button
fix: select item text can be seen
2026-01-28 15:03:46 -06:00
a1c68eedc2 Merge pull request 'theme-provider' (#22) from theme-provider into dev
Reviewed-on: #22
2026-01-28 01:50:41 +00:00
kayela
01722edad9 updated badge glitch 2026-01-27 17:30:50 -06:00
kayela
378b909398 removed transaction history from Profile.js 2026-01-27 16:38:21 -06:00
kayela
4ad1997bd5 Member tiers implementation intact. Icons updated to be Lucide React. Create/edit member tiers. Display member badge. Transaction history now in My profile dashboard. Adjusted Icons for badges. Added badges on my profile page 2026-01-27 16:30:26 -06:00
kayela
0d7e3a1286 tweaked statcard for better styling when digits are greater than 2 2026-01-27 15:19:19 -06:00
kayela
0c3d4a4edd Updated stat cards to be consistent with rest of codebase 2026-01-27 15:11:25 -06:00
kayela
97aa7860a9 feat: integrate TransactionHistory component into Dashboard and update styles for better UI consistency 2026-01-27 14:33:36 -06:00
Koncept Kit
467f34b42a - - New ThemeConfigContext provider that fetches theme on app load and applies it to the DOM (title, meta description, favicon, CSS variables,
theme-color)/- - Admin Theme settings page under Settings > Theme tab/- All logo references (5 components) now pull from the theme config with fallback to default
2026-01-27 21:32:22 +07:00
Koncept Kit
85070cf77b Update Footer to get current year 2026-01-27 16:44:56 +07:00
9dcb8e3185 Merge pull request 'theme-provider' (#20) from theme-provider into dev
Reviewed-on: #20
2026-01-27 08:41:49 +00:00
kayela
a88388ed5d Merge branch 'dev' into theme-provider 2026-01-27 00:53:27 -06:00
kayela
91e264bf7a feat: enhance Dashboard with transaction history and membership info; refactor layout for improved user experience 2026-01-27 00:32:14 -06:00
kayela
333ce62710 feat: add year filtering and search functionality to Bylaws and Financials pages; enhance report grouping by year 2026-01-26 15:18:27 -06:00
kayela
3c0b1396bc feat: enhance dialog components with overflow handling and update placeholder text for consistency 2026-01-26 14:47:04 -06:00
kayela
1ae82fc4e4 fix: badge text styling on hover in settings 2026-01-26 14:06:45 -06:00
kayela
ac8d40112e feat: add AdminMemberTiers page, MemberBadge component, and SettingsLayout; refactor routes and sidebar for improved navigation 2026-01-26 13:58:44 -06:00
kayela
7ee5cb0d9c feat: update AdminMembers and AdminStaff components for improved statistics display and fix typo in MembersDirectory 2026-01-25 12:55:26 -06:00
kayela
4548d959d7 feat: implement UsersContext and refactor user management hooks for improved user data handling 2026-01-25 12:17:30 -06:00
kayela
f2dd053320 feat: enhance AdminRoles to manage permissions with loading state and role slug updates 2026-01-22 15:23:50 -06:00
kayela
554b599599 feat: refactor AdminMembers and AdminStaff to utilize useMembers hook for improved member management 2026-01-22 14:47:34 -06:00
kayela
ac879b69b4 feat: Introduce StatusBadge component for consistent status representation
- Added StatusBadge component to standardize the display of user and membership statuses across various admin pages.
- Refactored AdminMembers, AdminPlans, AdminStaff, AdminSubscriptions, AdminUserView, AdminValidations, and MembersDirectory to utilize the new StatusBadge component.
- Removed redundant status badge logic from AdminMembers, AdminStaff, and AdminValidations.
- Updated AdminLayout to include a mobile-friendly sidebar toggle button with Menu icon.
- Created MemberCard component to encapsulate member display logic, improving code reusability.
- Adjusted various components to enhance user experience and maintain consistent styling.
2026-01-22 14:20:02 -06:00
kayela
6c844c0e19 feat: add @tailwindcss/line-clamp dependency and integrate responsive layout adjustments in Admin components for improved UI 2026-01-22 12:07:56 -06:00
66 changed files with 5651 additions and 1804 deletions

75
.dockerignore Normal file
View File

@@ -0,0 +1,75 @@
# Git
.git
.gitignore
# Dependencies
node_modules/
# Build output (we build inside Docker)
build/
dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Testing
coverage/
.nyc_output/
# Environment files (will be passed as build args)
.env
.env.local
.env.development
.env.development.local
.env.test
.env.test.local
.env.production
.env.production.local
*.env
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Docker
Dockerfile
docker-compose*.yml
.docker/
# Documentation
*.md
docs/
# OS files
.DS_Store
Thumbs.db
# Temporary files
tmp/
temp/
*.tmp
# ESLint cache
.eslintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Yarn
.yarn-integrity
.pnp.*
# Storybook
storybook-static/
# Design files (if any)
.superdesign/

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Frontend Dockerfile - React with multi-stage build
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json yarn.lock ./
# Install dependencies
RUN yarn install --frozen-lockfile
# Copy source code
COPY . .
# Build arguments for environment variables
ARG REACT_APP_BACKEND_URL
ENV REACT_APP_BACKEND_URL=$REACT_APP_BACKEND_URL
# Build the application
RUN yarn build
# Stage 2: Production with Nginx
FROM nginx:alpine AS production
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /app/build /usr/share/nginx/html
# Create non-root user for security
RUN adduser -D -g '' appuser && \
chown -R appuser:appuser /usr/share/nginx/html && \
chown -R appuser:appuser /var/cache/nginx && \
chown -R appuser:appuser /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R appuser:appuser /var/run/nginx.pid
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

179
README.md
View File

@@ -999,3 +999,182 @@ api.interceptors.response.use(
**Last Updated**: December 18, 2024 **Last Updated**: December 18, 2024
**Version**: 1.0.0 **Version**: 1.0.0
**Maintainer**: LOAF Development Team **Maintainer**: LOAF Development Team
**Backend API**
**Auth**
- POST `/api/auth/register`
- GET `/api/auth/verify-email`
- POST `/api/auth/resend-verification-email`
- POST `/api/auth/login`
- POST `/api/auth/forgot-password`
- POST `/api/auth/reset-password`
- GET `/api/auth/me`
- GET `/api/auth/permissions`
**Users**
- PUT `/api/users/change-password`
- GET `/api/users/profile`
- PUT `/api/users/profile`
**Members**
- GET `/api/members/directory` (defined twice in code)
- GET `/api/members/directory/{user_id}`
- GET `/api/members/profile`
- PUT `/api/members/profile`
- POST `/api/members/profile/upload-photo`
- DELETE `/api/members/profile/delete-photo`
- GET `/api/members/calendar/events`
- GET `/api/members/gallery`
- GET `/api/members/event-activity`
**Events (public/member)**
- GET `/api/events`
- GET `/api/events/{event_id}`
- GET `/api/events/{event_id}/gallery`
- POST `/api/events/{event_id}/rsvp`
- GET `/api/events/{event_id}/download.ics`
**Calendars**
- GET `/api/calendars/subscribe.ics`
- GET `/api/calendars/all-events.ics`
**Newsletters (public)**
- GET `/api/newsletters`
- GET `/api/newsletters/years`
**Financials (public)**
- GET `/api/financials`
**Bylaws (public)**
- GET `/api/bylaws/current`
- GET `/api/bylaws/history`
**Config/Diagnostics**
- GET `/api/config`
- GET `/api/config/limits`
- GET `/api/diagnostics/cors`
**Invitations**
- GET `/api/invitations/verify/{token}`
- POST `/api/invitations/accept`
**Subscriptions**
- GET `/api/subscriptions/plans`
- POST `/api/subscriptions/checkout`
**Donations**
- POST `/api/donations/checkout`
**Contact**
- POST `/api/contact`
**Admin Calendar**
- POST `/api/admin/calendar/sync/{event_id}`
- DELETE `/api/admin/calendar/unsync/{event_id}`
**Admin Event Gallery**
- POST `/api/admin/events/{event_id}/gallery`
- DELETE `/api/admin/event-gallery/{image_id}`
- PUT `/api/admin/event-gallery/{image_id}`
**Admin Events**
- POST `/api/admin/events`
- PUT `/api/admin/events/{event_id}`
- GET `/api/admin/events/{event_id}`
- GET `/api/admin/events/{event_id}/rsvps`
- PUT `/api/admin/events/{event_id}/attendance`
- GET `/api/admin/events`
- DELETE `/api/admin/events/{event_id}`
**Admin Storage**
- GET `/api/admin/storage/usage`
- GET `/api/admin/storage/breakdown`
**Admin Users & Invitations**
- GET `/api/admin/users`
- GET `/api/admin/users/invitations`
- GET `/api/admin/users/export`
- GET `/api/admin/users/{user_id}`
- PUT `/api/admin/users/{user_id}`
- PUT `/api/admin/users/{user_id}/validate`
- PUT `/api/admin/users/{user_id}/status`
- POST `/api/admin/users/{user_id}/reject`
- POST `/api/admin/users/{user_id}/activate-payment`
- PUT `/api/admin/users/{user_id}/reset-password`
- PUT `/api/admin/users/{user_id}/role`
- POST `/api/admin/users/{user_id}/resend-verification`
- POST `/api/admin/users/{user_id}/upload-photo`
- DELETE `/api/admin/users/{user_id}/delete-photo`
- POST `/api/admin/users/create`
- POST `/api/admin/users/invite`
- POST `/api/admin/users/invitations/{invitation_id}/resend`
- DELETE `/api/admin/users/invitations/{invitation_id}`
- POST `/api/admin/users/import`
- GET `/api/admin/users/import-jobs`
- GET `/api/admin/users/import-jobs/{job_id}`
**Admin Imports**
- POST `/api/admin/import/upload-csv`
- GET `/api/admin/import/{job_id}/preview`
- POST `/api/admin/import/{job_id}/execute`
- POST `/api/admin/import/{job_id}/rollback`
- GET `/api/admin/import/{job_id}/status`
- GET `/api/admin/import/{job_id}/errors/download`
**Admin Subscriptions**
- GET `/api/admin/subscriptions/plans`
- GET `/api/admin/subscriptions/plans/{plan_id}`
- POST `/api/admin/subscriptions/plans`
- PUT `/api/admin/subscriptions/plans/{plan_id}`
- DELETE `/api/admin/subscriptions/plans/{plan_id}`
- GET `/api/admin/subscriptions`
- GET `/api/admin/subscriptions/stats`
- PUT `/api/admin/subscriptions/{subscription_id}`
- POST `/api/admin/subscriptions/{subscription_id}/cancel`
- GET `/api/admin/subscriptions/export`
**Admin Donations**
- GET `/api/admin/donations`
- GET `/api/admin/donations/stats`
- GET `/api/admin/donations/export`
**Admin Newsletters**
- POST `/api/admin/newsletters`
- PUT `/api/admin/newsletters/{newsletter_id}`
- DELETE `/api/admin/newsletters/{newsletter_id}`
**Admin Financials**
- POST `/api/admin/financials`
- PUT `/api/admin/financials/{report_id}`
- DELETE `/api/admin/financials/{report_id}`
**Admin Bylaws**
- POST `/api/admin/bylaws`
- PUT `/api/admin/bylaws/{bylaws_id}`
- DELETE `/api/admin/bylaws/{bylaws_id}`
**Admin Roles**
- GET `/api/admin/roles`
- GET `/api/admin/roles/assignable`
- POST `/api/admin/roles`
- GET `/api/admin/roles/{role_id}`
- PUT `/api/admin/roles/{role_id}`
- DELETE `/api/admin/roles/{role_id}`
- GET `/api/admin/roles/{role_id}/permissions`
- PUT `/api/admin/roles/{role_id}/permissions`
**Admin Permissions**
- GET `/api/admin/permissions`
- GET `/api/admin/permissions/modules`
- GET `/api/admin/permissions/roles/{role}`
- PUT `/api/admin/permissions/roles/{role}`
- POST `/api/admin/permissions/seed`
**Admin Stripe Settings**
- GET `/api/admin/settings/stripe/status`
- POST `/api/admin/settings/stripe/test-connection`
- PUT `/api/admin/settings/stripe`
**Webhooks**
- POST `/api/webhooks/stripe`

44
nginx.conf Normal file
View File

@@ -0,0 +1,44 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Handle React Router - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Disable access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@@ -36,6 +36,7 @@
"@radix-ui/react-tooltip": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.4",
"@stripe/react-stripe-js": "^2.0.0", "@stripe/react-stripe-js": "^2.0.0",
"@stripe/stripe-js": "^2.0.0", "@stripe/stripe-js": "^2.0.0",
"@tailwindcss/line-clamp": "^0.4.4",
"axios": "^1.8.4", "axios": "^1.8.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -23,7 +23,9 @@ import AdminStaff from './pages/admin/AdminStaff';
import AdminMembers from './pages/admin/AdminMembers'; import AdminMembers from './pages/admin/AdminMembers';
import AdminPermissions from './pages/admin/AdminPermissions'; import AdminPermissions from './pages/admin/AdminPermissions';
import AdminSettings from './pages/admin/AdminSettings'; import AdminSettings from './pages/admin/AdminSettings';
import AdminMemberTiers from './pages/admin/AdminMemberTiers';
import AdminRoles from './pages/admin/AdminRoles'; import AdminRoles from './pages/admin/AdminRoles';
import AdminTheme from './pages/admin/AdminTheme';
import AdminEvents from './pages/admin/AdminEvents'; import AdminEvents from './pages/admin/AdminEvents';
import AdminEventAttendance from './pages/admin/AdminEventAttendance'; import AdminEventAttendance from './pages/admin/AdminEventAttendance';
import AdminValidations from './pages/admin/AdminValidations'; import AdminValidations from './pages/admin/AdminValidations';
@@ -31,6 +33,7 @@ import AdminPlans from './pages/admin/AdminPlans';
import AdminSubscriptions from './pages/admin/AdminSubscriptions'; import AdminSubscriptions from './pages/admin/AdminSubscriptions';
import AdminDonations from './pages/admin/AdminDonations'; import AdminDonations from './pages/admin/AdminDonations';
import AdminLayout from './layouts/AdminLayout'; import AdminLayout from './layouts/AdminLayout';
import SettingsLayout from './layouts/SettingsLayout';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider, useAuth } from './context/AuthContext';
import MemberRoute from './components/MemberRoute'; import MemberRoute from './components/MemberRoute';
import MemberCalendar from './pages/members/MemberCalendar'; import MemberCalendar from './pages/members/MemberCalendar';
@@ -286,18 +289,22 @@ function App() {
} /> } />
<Route path="/admin/permissions" element={ <Route path="/admin/permissions" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <Navigate to="/admin/settings/permissions" replace />
<AdminRoles />
</AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
<Route path="/admin/settings" element={ <Route path="/admin/settings" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <AdminLayout>
<AdminSettings /> <SettingsLayout />
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> }>
<Route index element={<Navigate to="stripe" replace />} />
<Route path="stripe" element={<AdminSettings />} />
<Route path="permissions" element={<AdminRoles />} />
<Route path="member-tiers" element={<AdminMemberTiers />} />
<Route path="theme" element={<AdminTheme />} />
</Route>
{/* 404 - Catch all undefined routes */} {/* 404 - Catch all undefined routes */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import api from '../utils/api'; import api from '../utils/api';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { import {
@@ -32,6 +33,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);
const [storageUsed, setStorageUsed] = useState(0); const [storageUsed, setStorageUsed] = useState(0);
@@ -169,13 +171,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
path: '/admin/bylaws', path: '/admin/bylaws',
disabled: false disabled: false
}, },
{
name: 'Permissions',
icon: Shield,
path: '/admin/permissions',
disabled: false,
superadminOnly: true
},
{ {
name: 'Settings', name: 'Settings',
icon: Settings, icon: Settings,
@@ -287,7 +283,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]"> <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"> <Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
<img <img
src={`${process.env.PUBLIC_URL}/loaf-logo.png`} src={getLogoUrl()}
alt="LOAF Logo" alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8' className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`} }`}

View File

@@ -66,7 +66,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-background"> <DialogContent className="sm:max-w-md bg-background overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--lavender-300)]"> <div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--lavender-300)]">

View File

@@ -77,7 +77,7 @@ export default function ChangeRoleDialog({ open, onClose, user, onSuccess }) {
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px] overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-[#664fa3]" /> <Shield className="h-5 w-5 text-[#664fa3]" />

View File

@@ -181,7 +181,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)} onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="John" placeholder="Jane"
/> />
{errors.first_name && ( {errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p> <p className="text-sm text-red-500">{errors.first_name}</p>

View File

@@ -106,7 +106,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] rounded-2xl"> <DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" /> <UserPlus className="h-6 w-6" />
@@ -165,7 +165,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)} onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="John" placeholder="Jane"
/> />
{errors.first_name && ( {errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p> <p className="text-sm text-red-500">{errors.first_name}</p>

View File

@@ -0,0 +1,576 @@
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 { Textarea } from './ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
import { Card } from './ui/card';
import { toast } from 'sonner';
import { Loader2, Repeat, Search, Calendar, Heart, X, User } from 'lucide-react';
const CreateSubscriptionDialog = ({ open, onOpenChange, onSuccess }) => {
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [searchLoading, setSearchLoading] = useState(false);
const [allUsers, setAllUsers] = useState([]);
// Plan state
const [plans, setPlans] = useState([]);
const [selectedPlan, setSelectedPlan] = useState(null);
const [useCustomPeriod, setUseCustomPeriod] = useState(false);
// Form state
const [formData, setFormData] = useState({
plan_id: '',
amount: '',
payment_date: new Date().toISOString().split('T')[0],
payment_method: 'cash',
custom_period_start: new Date().toISOString().split('T')[0],
custom_period_end: '',
notes: ''
});
const [loading, setLoading] = useState(false);
// Fetch users and plans when dialog opens
useEffect(() => {
const fetchData = async () => {
if (!open) return;
try {
const [usersResponse, plansResponse] = await Promise.all([
api.get('/admin/users'),
api.get('/admin/subscriptions/plans')
]);
setAllUsers(usersResponse.data);
setPlans(plansResponse.data.filter(p => p.active));
} catch (error) {
toast.error('Failed to load data');
}
};
fetchData();
}, [open]);
// Filter users based on search query
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
setSearchLoading(true);
const query = searchQuery.toLowerCase();
const filtered = allUsers.filter(user =>
user.first_name?.toLowerCase().includes(query) ||
user.last_name?.toLowerCase().includes(query) ||
user.email?.toLowerCase().includes(query)
).slice(0, 10); // Limit to 10 results
setSearchResults(filtered);
setSearchLoading(false);
}, [searchQuery, allUsers]);
// Update amount when plan changes
useEffect(() => {
if (selectedPlan && !formData.amount) {
const suggestedAmount = (selectedPlan.suggested_price_cents || selectedPlan.minimum_price_cents || selectedPlan.price_cents) / 100;
setFormData(prev => ({
...prev,
amount: suggestedAmount.toFixed(2)
}));
}
}, [selectedPlan]);
// Calculate donation breakdown
const getAmountBreakdown = () => {
if (!selectedPlan || !formData.amount) return null;
const totalCents = Math.round(parseFloat(formData.amount) * 100);
const minimumCents = selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000;
const donationCents = Math.max(0, totalCents - minimumCents);
return {
total: totalCents,
base: minimumCents,
donation: donationCents
};
};
const formatPrice = (cents) => {
return `$${(cents / 100).toFixed(2)}`;
};
const breakdown = getAmountBreakdown();
const handleSelectUser = (user) => {
setSelectedUser(user);
setSearchQuery('');
setSearchResults([]);
};
const handleClearUser = () => {
setSelectedUser(null);
setFormData({
plan_id: '',
amount: '',
payment_date: new Date().toISOString().split('T')[0],
payment_method: 'cash',
custom_period_start: new Date().toISOString().split('T')[0],
custom_period_end: '',
notes: ''
});
setSelectedPlan(null);
setUseCustomPeriod(false);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!selectedUser) {
toast.error('Please select a user');
return;
}
if (!formData.plan_id) {
toast.error('Please select a subscription plan');
return;
}
if (!formData.amount || parseFloat(formData.amount) <= 0) {
toast.error('Please enter a valid payment amount');
return;
}
// Validate minimum amount
const amountCents = Math.round(parseFloat(formData.amount) * 100);
const minimumCents = selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000;
if (amountCents < minimumCents) {
toast.error(`Amount must be at least ${formatPrice(minimumCents)}`);
return;
}
if (useCustomPeriod && (!formData.custom_period_start || !formData.custom_period_end)) {
toast.error('Please specify both start and end dates for custom period');
return;
}
setLoading(true);
try {
const payload = {
plan_id: formData.plan_id,
amount_cents: amountCents,
payment_date: new Date(formData.payment_date).toISOString(),
payment_method: formData.payment_method,
override_plan_dates: useCustomPeriod,
notes: formData.notes || null
};
if (useCustomPeriod) {
payload.custom_period_start = new Date(formData.custom_period_start).toISOString();
payload.custom_period_end = new Date(formData.custom_period_end).toISOString();
}
await api.post(`/admin/users/${selectedUser.id}/activate-payment`, payload);
toast.success(`Subscription created for ${selectedUser.first_name} ${selectedUser.last_name}!`);
// Reset form
handleClearUser();
onOpenChange(false);
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to create subscription';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const handleClose = () => {
handleClearUser();
setSearchQuery('');
setSearchResults([]);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<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" }}>
<Repeat className="h-6 w-6" />
Create Subscription
</DialogTitle>
<DialogDescription className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Search for an existing member and create a subscription with manual payment processing.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* User Search Section */}
{!selectedUser ? (
<div className="space-y-3">
<Label className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Search Member
</Label>
<div className="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 name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
{searchLoading && (
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-brand-purple" />
)}
</div>
{/* Search Results */}
{searchResults.length > 0 && (
<Card className="border-2 border-[var(--neutral-800)] rounded-xl overflow-hidden">
<div className="max-h-60 overflow-y-auto">
{searchResults.map((user) => (
<button
key={user.id}
type="button"
onClick={() => handleSelectUser(user)}
className="w-full p-3 text-left hover:bg-[var(--lavender-400)] transition-colors border-b border-[var(--neutral-800)] last:border-b-0"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
<User className="h-5 w-5 text-brand-purple" />
</div>
<div>
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.email}
</p>
</div>
</div>
</button>
))}
</div>
</Card>
)}
{searchQuery && !searchLoading && searchResults.length === 0 && (
<p className="text-sm text-brand-purple text-center py-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No members found matching "{searchQuery}"
</p>
)}
</div>
) : (
/* Selected User Card */
<Card className="p-4 bg-[var(--lavender-400)] border-2 border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
<User className="h-6 w-6 text-brand-purple" />
</div>
<div>
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedUser.first_name} {selectedUser.last_name}
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedUser.email}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearUser}
className="text-brand-purple hover:bg-[var(--neutral-800)]/20"
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
)}
{/* Payment Form - Only show when user is selected */}
{selectedUser && (
<>
{/* Plan Selection */}
<div className="space-y-2">
<Label htmlFor="plan_id" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Plan
</Label>
<Select
value={formData.plan_id}
onValueChange={(value) => {
const plan = plans.find(p => p.id === value);
setSelectedPlan(plan);
const suggestedAmount = plan ? (plan.suggested_price_cents || plan.minimum_price_cents || plan.price_cents) / 100 : '';
setFormData({
...formData,
plan_id: value,
amount: suggestedAmount ? suggestedAmount.toFixed(2) : ''
});
}}
>
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Select subscription plan" />
</SelectTrigger>
<SelectContent>
{plans.map(plan => {
const minPrice = (plan.minimum_price_cents || plan.price_cents) / 100;
const sugPrice = plan.suggested_price_cents ? (plan.suggested_price_cents / 100) : null;
return (
<SelectItem key={plan.id} value={plan.id}>
{plan.name} - ${minPrice.toFixed(2)}{sugPrice && sugPrice > minPrice ? ` (Suggested: $${sugPrice.toFixed(2)})` : ''}/{plan.billing_cycle}
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedPlan && (
<p className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedPlan.description || `${selectedPlan.billing_cycle} subscription`}
</p>
)}
</div>
{/* Payment Amount */}
<div className="space-y-2">
<Label htmlFor="amount" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Amount ($)
</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
placeholder="Enter amount"
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
required
/>
{selectedPlan && (
<p className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)}
</p>
)}
</div>
{/* Amount Breakdown */}
{breakdown && breakdown.total >= breakdown.base && (
<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-[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-[var(--orange-light)]">
<span className="flex items-center gap-1">
<Heart className="h-4 w-4" />
Additional Donation:
</span>
<span className="font-semibold">{formatPrice(breakdown.donation)}</span>
</div>
)}
<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>
</div>
</Card>
)}
{/* Payment Date */}
<div className="space-y-2">
<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-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-[var(--neutral-800)] focus:border-brand-purple"
required
/>
</div>
</div>
{/* Payment Method */}
<div className="space-y-2">
<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-[var(--neutral-800)]">
<SelectValue placeholder="Select payment method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cash">Cash</SelectItem>
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
{/* Subscription Period */}
<div className="space-y-3">
<Label className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Period
</Label>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="use_custom_period"
checked={useCustomPeriod}
onChange={(e) => setUseCustomPeriod(e.target.checked)}
className="rounded border-[var(--neutral-800)]"
/>
<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>
{useCustomPeriod ? (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="custom_period_start" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Start Date
</Label>
<Input
id="custom_period_start"
type="date"
value={formData.custom_period_start}
onChange={(e) => setFormData({ ...formData, custom_period_start: e.target.value })}
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-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
End Date
</Label>
<Input
id="custom_period_end"
type="date"
value={formData.custom_period_end}
onChange={(e) => setFormData({ ...formData, custom_period_end: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
required={useCustomPeriod}
/>
</div>
</div>
) : (
selectedPlan && (
<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-[var(--purple-ink)]">Plan uses custom billing cycle:</span>
<br />
{(() => {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const startMonth = months[(selectedPlan.custom_cycle_start_month || 1) - 1];
const endMonth = months[(selectedPlan.custom_cycle_end_month || 12) - 1];
return `${startMonth} ${selectedPlan.custom_cycle_start_day} - ${endMonth} ${selectedPlan.custom_cycle_end_day} (recurring annually)`;
})()}
</p>
<p className="text-xs">
Subscription will end on the upcoming cycle end date based on today's date.
</p>
</>
) : (
<p>
Will use plan's billing cycle: <span className="font-medium">{selectedPlan.billing_cycle}</span>
<br />
Starts today, ends {selectedPlan.billing_cycle === 'monthly' ? '30 days' :
selectedPlan.billing_cycle === 'quarterly' ? '90 days' :
selectedPlan.billing_cycle === 'yearly' ? '1 year' :
selectedPlan.billing_cycle === 'lifetime' ? 'lifetime' : '1 year'} from now
</p>
)}
</div>
)
)}
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Notes (Optional)
</Label>
<Textarea
id="notes"
placeholder="Additional notes about the payment..."
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
/>
</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 || !selectedUser}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Repeat className="h-4 w-4 mr-2" />
Create Subscription
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default CreateSubscriptionDialog;

View File

@@ -0,0 +1,281 @@
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 InviteMemberDialog = ({ 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 overflow-y-auto max-h-[90vh]">
<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 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 member. 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="member@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="Jane"
/>
</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>
</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 InviteMemberDialog;

View File

@@ -123,7 +123,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] rounded-2xl"> <DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6" /> <Mail className="h-6 w-6" />
@@ -196,7 +196,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)} onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="John" placeholder="Jane"
/> />
</div> </div>

View File

@@ -0,0 +1,19 @@
// src/components/MemberBadge.js
import React from 'react';
import { Badge } from './ui/badge';
import { getTierForMember } from '../utils/member-tiers';
import { getTierIcon } from '../config/memberTierIcons';
const MemberBadge = ({ memberSince, tiers }) => {
const tier = getTierForMember(memberSince, tiers);
const Icon = getTierIcon(tier.iconKey);
return (
<Badge className={`px-3 py-2 rounded-md text-sm flex items-center gap-2 border hover:text-white ${tier.badgeClass}`}>
<Icon className="size-6" />
{tier.label}
</Badge>
);
};
export default MemberBadge;

View File

@@ -0,0 +1,187 @@
import React from 'react'
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
import MemberBadge from './MemberBadge';
// Helper function to get initials
const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
};
// Helper function to ensure social media URLs have proper protocol
const getSocialMediaLink = (url) => {
if (!url) return null;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return `https://${url}`;
}
return url;
};
const MemberCard = ({ member, onViewProfile, tiers }) => {
const memberSince = member.member_since || member.created_at;
return (
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
{/* Member Tier Badge */}
<div className='flex justify-end items-center mb-2'>
<MemberBadge memberSince={memberSince} tiers={tiers} />
</div>
<div className="flex justify-center mb-4">
{member.profile_photo_url ? (
<img
src={member.profile_photo_url}
alt={`${member.first_name} ${member.last_name}`}
className="w-32 h-32 rounded-full object-cover border-4 border-[var(--neutral-800)]"
/>
) : (
<div className="w-32 h-32 rounded-full bg-[var(--neutral-800)] border-4 border-[var(--neutral-800)] flex items-center justify-center">
<span className="text-4xl font-semibold text-brand-purple " style={{ fontFamily: "'Inter', sans-serif" }}>
{getInitials(member.first_name, member.last_name)}
</span>
</div>
)}
</div>
{/* Name */}
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] text-center mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
{member.first_name} {member.last_name}
</h3>
{/* Partner Name */}
{member.directory_partner_name && (
<div className="flex items-center justify-center gap-2 mb-4">
<Heart className="h-4 w-4 text-[var(--orange-light)]" />
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Partner: {member.directory_partner_name}
</span>
</div>
)}
{/* Bio */}
{member.directory_bio && (
<p className="text-brand-purple text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{member.directory_bio}
</p>
)}
{/* Member Since */}
{memberSince && (
<div className="flex items-center justify-center gap-2 mb-4">
<Calendar className="h-4 w-4 text-brand-purple " />
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Member since {new Date(memberSince).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
})}
</span>
</div>
)}
{/* Contact Information */}
<div className="space-y-3 mb-4">
{member.directory_email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-brand-purple flex-shrink-0" />
<a
href={`mailto:${member.directory_email}`}
className="text-brand-purple hover:text-[var(--purple-ink)] truncate"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{member.directory_email}
</a>
</div>
)}
{member.directory_phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-brand-purple flex-shrink-0" />
<a
href={`tel:${member.directory_phone}`}
className="text-brand-purple hover:text-[var(--purple-ink)]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{member.directory_phone}
</a>
</div>
)}
{member.directory_address && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="h-4 w-4 text-brand-purple flex-shrink-0 mt-0.5" />
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{member.directory_address}
</span>
</div>
)}
</div>
{/* Social Media Links */}
{(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
<div className="pt-4 border-t border-[var(--neutral-800)]">
<div className="flex justify-center gap-3">
{member.social_media_facebook && (
<a
href={getSocialMediaLink(member.social_media_facebook)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
title="Facebook"
>
<Facebook className="h-5 w-5 text-[var(--blue-facebook)]" />
</a>
)}
{member.social_media_instagram && (
<a
href={getSocialMediaLink(member.social_media_instagram)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
title="Instagram"
>
<Instagram className="h-5 w-5 text-[var(--red-instagram)]" />
</a>
)}
{member.social_media_twitter && (
<a
href={getSocialMediaLink(member.social_media_twitter)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
title="Twitter/X"
>
<Twitter className="h-5 w-5 text-[var(--blue-twitter)]" />
</a>
)}
{member.social_media_linkedin && (
<a
href={getSocialMediaLink(member.social_media_linkedin)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
title="LinkedIn"
>
<Linkedin className="h-5 w-5 text-[var(--blue-linkedin)]" />
</a>
)}
</div>
</div>
)}
{/* View Profile Button */}
<div className="pt-4 mt-4 border-t border-[var(--neutral-800)]">
<Button
onClick={() => onViewProfile?.(member.id)}
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white rounded-full py-5"
>
<UserCircle className="h-4 w-4 mr-2" />
View Full Profile
</Button>
</div>
</Card>
);
};
export default MemberCard

View File

@@ -111,7 +111,7 @@ const MemberFooter = () => {
<a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</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> <a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
</div> </div>
<p>© 2025 LOAF. All rights reserved.</p> <p>© {new Date().getFullYear()} LOAF. All rights reserved.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ChevronDown, Menu, X } from 'lucide-react'; import { ChevronDown, Menu, X } from 'lucide-react';
import { import {
@@ -12,11 +13,12 @@ import {
const Navbar = () => { const Navbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// LOAF logo (local) // Get logo URL from theme config (with fallback to default)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const loafLogo = getLogoUrl();
const handleLogout = () => { const handleLogout = () => {
logout(); logout();

View File

@@ -156,8 +156,8 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
if (!user) return null; if (!user) return null;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange} className="">
<DialogContent className="sm:max-w-[600px] bg-background rounded-2xl"> <DialogContent className="sm:max-w-[600px] bg-background rounded-2xl overflow-y-auto max-h-[90vh] p-6">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Activate Manual Payment Activate Manual Payment

View File

@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useThemeConfig } from '../context/ThemeConfigContext';
const PublicFooter = () => { const PublicFooter = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return ( return (
<> <>
@@ -60,7 +62,7 @@ const PublicFooter = () => {
</Link> </Link>
</nav> </nav>
<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" }}> <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. © {new Date().getFullYear()} LOAF. All Rights Reserved.
</p> </p>
<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" }}> <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{' '} Designed and Managed by{' '}

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import { ChevronDown, Menu, X } from 'lucide-react'; import { ChevronDown, Menu, X } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@@ -12,6 +13,7 @@ import {
const PublicNavbar = () => { const PublicNavbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -30,8 +32,8 @@ const PublicNavbar = () => {
return location.pathname.startsWith('/about'); return location.pathname.startsWith('/about');
}; };
// LOAF logo (local) // Get logo URL from theme config (with fallback to default)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const loafLogo = getLogoUrl();
const handleAuthAction = () => { const handleAuthAction = () => {
if (user) { if (user) {
@@ -75,8 +77,24 @@ const PublicNavbar = () => {
<div className='sticky top-0 inset-x-0 z-50'> <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"> <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'> <div className='flex gap-4 sm:gap-6 items-center'>
{user && (
<span
className="text-white text-base font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Welcome, {user.first_name}
</span>
)}
{(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link
to="/admin"
className="text-white text-base font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Dashboard
</Link>
)}
<button <button
onClick={handleAuthAction} onClick={handleAuthAction}
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer" className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
@@ -176,7 +194,71 @@ const PublicNavbar = () => {
Members Only Members Only
</Link> </Link>
)} )}
<Link {user && (
<>
<Link
to="/events"
className={getDesktopLinkClasses('/events')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Events
</Link>
<Link
to="/members/calendar"
className={getDesktopLinkClasses('/members/calendar')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Calendar
</Link>
<Link
to="/members/directory"
className={getDesktopLinkClasses('/members/directory')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Directory
</Link>
<Link
to="/members/gallery"
className={getDesktopLinkClasses('/members/gallery')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Gallery
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`${location.pathname.startsWith('/members/newsletters') || location.pathname.startsWith('/members/financials') || location.pathname.startsWith('/members/bylaws')
? "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" }}>
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: "'Nunito Sans', 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: "'Nunito Sans', 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: "'Nunito Sans', sans-serif" }}>
Bylaws
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{/* <Link
to="/resources" to="/resources"
className={getDesktopLinkClasses('/resources')} className={getDesktopLinkClasses('/resources')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
@@ -189,7 +271,7 @@ const PublicNavbar = () => {
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
> >
Contact Us Contact Us
</Link> </Link> */}
</nav> </nav>
</header> </header>
@@ -219,6 +301,18 @@ const PublicNavbar = () => {
</button> </button>
</div> </div>
{/* User Info */}
{user && (
<div className="px-6 py-4 border-b border-[var(--purple-deep)]">
<p className="text-white text-sm opacity-90" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Welcome,
</p>
<p className="text-white font-semibold text-base" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.first_name} {user.last_name}
</p>
</div>
)}
{/* Navigation Links */} {/* Navigation Links */}
<nav className="flex flex-col p-6 space-y-4"> <nav className="flex flex-col p-6 space-y-4">
<Link <Link
@@ -284,6 +378,80 @@ const PublicNavbar = () => {
</Link> </Link>
)} )}
{user && (
<>
<Link
to="/events"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileLinkClasses('/events')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Events
</Link>
<Link
to="/members/calendar"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileLinkClasses('/members/calendar')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Calendar
</Link>
<Link
to="/members/directory"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileLinkClasses('/members/directory')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Directory
</Link>
<Link
to="/members/gallery"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileLinkClasses('/members/gallery')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Gallery
</Link>
{/* Documents Section */}
<div className="space-y-2">
<p
className={`text-base font-semibold px-4 py-2 rounded-md ${location.pathname.startsWith('/members/newsletters') || location.pathname.startsWith('/members/financials') || location.pathname.startsWith('/members/bylaws') ? 'text-[var(--orange-light)]' : 'text-white'}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Documents
</p>
<Link
to="/members/newsletters"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileSubLinkClasses('/members/newsletters')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Newsletters
</Link>
<Link
to="/members/financials"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileSubLinkClasses('/members/financials')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Financials
</Link>
<Link
to="/members/bylaws"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileSubLinkClasses('/members/bylaws')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Bylaws
</Link>
</div>
</>
)}
<Link <Link
to="/resources" to="/resources"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
@@ -304,6 +472,16 @@ const PublicNavbar = () => {
{/* Auth Actions */} {/* Auth Actions */}
<div className="pt-4 border-t border-[var(--purple-deep)] space-y-2"> <div className="pt-4 border-t border-[var(--purple-deep)] space-y-2">
{(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link
to="/admin"
onClick={() => setIsMobileMenuOpen(false)}
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" }}
>
Dashboard
</Link>
)}
<button <button
onClick={() => { onClick={() => {
handleAuthAction(); handleAuthAction();

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { CreditCard, Shield, Star, Palette } from 'lucide-react';
const settingsItems = [
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
{ label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star },
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
];
const SettingsTabs = () => {
const location = useLocation();
return (
<div className="w-full border-b border-border">
<nav className="flex gap-1 overflow-x-auto pb-px -mb-px" aria-label="Settings tabs">
{settingsItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<NavLink
key={item.label}
to={item.path}
className={`
flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap
border-b-2 transition-all duration-200
${isActive
? 'border-primary text-primary bg-primary/5'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}
`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-primary' : ''}`} />
<span>{item.label}</span>
</NavLink>
);
})}
</nav>
</div>
);
};
export default SettingsTabs;

View File

@@ -7,30 +7,60 @@ export const StatCard = ({
icon: Icon, icon: Icon,
iconBgClass, iconBgClass,
dataTestId, dataTestId,
}) => ( }) => {
<Card const valueString = value == null ? "" : String(value);
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"> const digitCount =
<p valueString.replace(/\D/g, "").length || valueString.length;
className="text-6xl font-semibold text-[var(--purple-ink)] mb-1"
style={{ fontFamily: "'Inter', sans-serif" }} const getValueFontSize = () => {
> switch (true) {
{value} case digitCount <= 2:
</p> // 3.75rem for 3 or fewer digits
</div> return "3.75rem";
</div> case digitCount <= 6:
<p // Scale down for more digits
className="text-sm text-brand-purple " return "clamp(2rem, 5cqi, 3rem)";
style={{ fontFamily: "'Nunito Sans', sans-serif" }} case digitCount <= 9:
return "clamp(1.5rem, 4cqi, 2.5rem)";
default:
return "clamp(1.25rem, 3cqi, 2rem)";
}
};
const valueFontSize = getValueFontSize();
return (
<Card
className="p-6 flex flex-col justify-between bg-background rounded-2xl border border-[var(--neutral-800)]"
data-testid={dataTestId}
> >
{title} <div className="flex items-start gap-4 mb-4 justify-between">
</p> <div
</Card> className="space-y-8 "
); style={{
containerType: "inline-size",
maxWidth: "200px",
width: "100%",
}}
>
<p
className="font-semibold text-[var(--purple-ink)] mb-1"
style={{ fontSize: valueFontSize, lineHeight: 1 }}
>
{value}
</p>
</div>
<div className={`${iconBgClass} px-3 py-2 rounded-lg `}>
<Icon className="size-[valueFontSize]" />
</div>
</div>
<p
className="text-sm text-brand-purple "
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{title}
</p>
</Card>
);
};

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Badge } from './ui/badge';
const STATUS_BADGE_CONFIG = {
//status-based badges
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' },
rejected: { label: 'Rejected', className: 'bg-red-100 text-red-700' },
//role-based badges
finance: { label: 'Finance Manager', variant: 'purple' },
guest: { label: 'Guest', variant: 'gray' },
member: { label: 'Member', variant: 'purple' },
superadmin: { label: 'Superadmin', variant: 'purple' },
admin: { label: 'Admin', variant: 'purple' },
moderator: { label: 'Moderator', variant: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' },
staff: { label: 'Staff', variant: 'gray' },
media: { label: 'Media', variant: 'gray2' }
};
//todo: make shield icon dynamic based on status
const StatusBadge = ({ status }) => {
const statusConfig = STATUS_BADGE_CONFIG[status] || STATUS_BADGE_CONFIG.inactive;
return (
<Badge variant={statusConfig.variant} className=" px-3 py-1 rounded-md text-sm">
{/* <Shield className="h-3 w-3 mr-1 inline" /> */}
{statusConfig.label}
</Badge>
);
};
export default StatusBadge;

View File

@@ -70,9 +70,9 @@ const TransactionHistory = ({
<div className="flex items-start gap-3 mb-2 sm:mb-0"> <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'}`}> <div className={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
{isSubscription ? ( {isSubscription ? (
<CreditCard className="h-5 w-5 text-[var(--purple-lavender)]" /> <CreditCard className="h-5 w-5 text-white" />
) : ( ) : (
<Heart className="h-5 w-5 text-[var(--orange-light)]" /> <Heart className="h-5 w-5 text-white" />
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">

View File

@@ -0,0 +1,172 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react';
import StatusBadge from './StatusBadge';
const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
if (!user) return null;
const formatDate = (dateString) => {
if (!dateString) return '—';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatDateTime = (dateString) => {
if (!dateString) return '—';
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatPhoneNumber = (phone) => {
if (!phone) return '—';
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
}
return phone;
};
const InfoRow = ({ icon: Icon, label, value }) => (
<div className="flex items-start gap-3 py-3 border-b border-[var(--neutral-800)] last:border-b-0">
<div className="h-10 w-10 rounded-lg bg-[var(--lavender-400)] flex items-center justify-center flex-shrink-0">
<Icon className="h-5 w-5 text-brand-purple" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{label}
</p>
<p className="font-medium text-[var(--purple-ink)] break-words" style={{ fontFamily: "'Inter', sans-serif" }}>
{value || '—'}
</p>
</div>
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] 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" }}>
<FileText className="h-6 w-6" />
Registration Details
</DialogTitle>
<DialogDescription className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View the registration information for this member application.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* User Header Card */}
<Card className="p-4 bg-[var(--lavender-400)] border-2 border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
<User className="h-8 w-8 text-brand-purple" />
</div>
<div className="flex-1">
<p className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.email}
</p>
<div className="mt-2">
<StatusBadge status={user.status} />
</div>
</div>
</div>
</Card>
{/* Contact Information */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Contact Information
</h3>
<InfoRow icon={Mail} label="Email Address" value={user.email} />
<InfoRow icon={Phone} label="Phone Number" value={formatPhoneNumber(user.phone)} />
</Card>
{/* Registration Details */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Registration Details
</h3>
<InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} />
<InfoRow icon={UserCheck} label="Referred By" value={user.referred_by_member_name} />
<InfoRow icon={Clock} label="Email Verification Expires" value={formatDateTime(user.email_verification_expires_at)} />
</Card>
{/* Additional Information (if available) */}
{(user.address || user.city || user.state || user.zip_code) && (
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Address
</h3>
<div className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.address && <p>{user.address}</p>}
{(user.city || user.state || user.zip_code) && (
<p>
{[user.city, user.state, user.zip_code].filter(Boolean).join(', ')}
</p>
)}
</div>
</Card>
)}
{/* Notes (if available) */}
{user.notes && (
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Notes
</h3>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.notes}
</p>
</Card>
)}
{/* Rejection Reason (if rejected) */}
{user.status === 'rejected' && user.rejection_reason && (
<Card className="p-4 border border-red-300 bg-red-50 dark:bg-red-500/10 rounded-xl">
<h3 className="text-lg font-semibold text-red-600 mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Rejection Reason
</h3>
<p className="text-red-600" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.rejection_reason}
</p>
</Card>
)}
</div>
<DialogFooter>
<Button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-xl bg-[var(--purple-ink)] hover:bg-[var(--purple-ink)]/90 text-white"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ViewRegistrationDialog;

View File

@@ -9,7 +9,7 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", "border-transparent bg-primary text-primary-foreground shadow ",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:

View File

@@ -83,16 +83,16 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:text-white focus:text-white",
className className
)} )}
{...props}> {...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center ">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText className="">{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ))
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName

70
src/config/MemberTiers.js Normal file
View File

@@ -0,0 +1,70 @@
// src/config/memberTiers.js
/**
* Default member tier configuration
* Used as fallback when API is unavailable
* Format matches backend MemberTier interface
*/
export const DEFAULT_MEMBER_TIERS = [
{
id: 'new_member',
label: 'New Member',
minYears: 0,
maxYears: 0.999,
iconKey: 'sparkle',
badgeClass: 'bg-blue-100 text-blue-800 border-blue-200',
},
{
id: 'member_1_year',
label: '1 Year Member',
minYears: 1,
maxYears: 2.999,
iconKey: 'star',
badgeClass: 'bg-green-100 text-green-800 border-green-200',
},
{
id: 'member_3_year',
label: '3+ Year Member',
minYears: 3,
maxYears: 4.999,
iconKey: 'award',
badgeClass: 'bg-purple-100 text-purple-800 border-purple-200',
},
{
id: 'veteran',
label: 'Veteran Member',
minYears: 5,
maxYears: 999,
iconKey: 'crown',
badgeClass: 'bg-amber-100 text-amber-800 border-amber-200',
},
];
/**
* Available icon options for tier configuration
*/
export const TIER_ICON_OPTIONS = [
{ key: 'sparkle', label: 'Sparkle' },
{ key: 'star', label: 'Star' },
{ key: 'award', label: 'Award' },
{ key: 'crown', label: 'Crown' },
{ key: 'medal', label: 'Medal' },
{ key: 'trophy', label: 'Trophy' },
{ key: 'gem', label: 'Gem' },
{ key: 'heart', label: 'Heart' },
{ key: 'shield', label: 'Shield' },
];
/**
* Available badge color presets
*/
export const BADGE_COLOR_PRESETS = [
{ label: 'Blue', badgeClass: 'bg-blue-100 text-blue-800 border-blue-200' },
{ label: 'Green', badgeClass: 'bg-green-100 text-green-800 border-green-200' },
{ label: 'Purple', badgeClass: 'bg-purple-100 text-purple-800 border-purple-200' },
{ label: 'Amber', badgeClass: 'bg-amber-100 text-amber-800 border-amber-200' },
{ label: 'Red', badgeClass: 'bg-red-100 text-red-800 border-red-200' },
{ label: 'Teal', badgeClass: 'bg-teal-100 text-teal-800 border-teal-200' },
{ label: 'Pink', badgeClass: 'bg-pink-100 text-pink-800 border-pink-200' },
{ label: 'Indigo', badgeClass: 'bg-indigo-100 text-indigo-800 border-indigo-200' },
];

View File

@@ -0,0 +1,29 @@
// src/config/memberTierIcons.js
import { User, Star, Crown, Award, Sparkles, Medal, Trophy, Gem, Heart, Shield } from 'lucide-react';
/**
* Member tier icon mapping
* Maps iconKey strings from backend to Lucide React components
*/
export const MEMBER_TIER_ICONS = {
// Primary tier icons
sparkle: Sparkles,
sparkles: Sparkles,
star: Star,
award: Award,
crown: Crown,
// Additional options
medal: Medal,
trophy: Trophy,
gem: Gem,
heart: Heart,
shield: Shield,
user: User,
};
/**
* Get icon component by key with fallback
*/
export const getTierIcon = (iconKey) => {
return MEMBER_TIER_ICONS[iconKey?.toLowerCase()] || MEMBER_TIER_ICONS.sparkle;
};

View File

@@ -0,0 +1,161 @@
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
import axios from 'axios';
const ThemeConfigContext = createContext();
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
const DEFAULT_THEME = {
site_name: 'LOAF - Lesbians Over Age Fifty',
site_short_name: 'LOAF',
site_description: 'A community organization for lesbians over age fifty in Houston and surrounding areas.',
logo_url: null,
favicon_url: null,
colors: {
primary: '280 47% 27%',
primary_foreground: '0 0% 100%',
accent: '24 86% 55%',
brand_purple: '256 35% 47%',
brand_orange: '24 86% 55%',
brand_lavender: '262 46% 80%'
},
meta_theme_color: '#664fa3'
};
export const ThemeConfigProvider = ({ children }) => {
const [themeConfig, setThemeConfig] = useState(DEFAULT_THEME);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const applyThemeToDOM = useCallback((config) => {
// Apply CSS variables for colors
if (config.colors) {
const root = document.documentElement;
Object.entries(config.colors).forEach(([key, value]) => {
// Convert snake_case to kebab-case for CSS variable names
const cssVarName = `--${key.replace(/_/g, '-')}`;
root.style.setProperty(cssVarName, value);
});
}
// Update favicon
if (config.favicon_url) {
let link = document.querySelector("link[rel*='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = config.favicon_url;
}
// Update document title
if (config.site_name) {
document.title = config.site_name;
// Also store for use by pages that want to append their own title
window.__SITE_NAME__ = config.site_name;
}
// Update meta description
if (config.site_description) {
let metaDesc = document.querySelector("meta[name='description']");
if (!metaDesc) {
metaDesc = document.createElement('meta');
metaDesc.name = 'description';
document.head.appendChild(metaDesc);
}
metaDesc.content = config.site_description;
}
// Update meta theme-color for PWA
if (config.meta_theme_color) {
let meta = document.querySelector("meta[name='theme-color']");
if (!meta) {
meta = document.createElement('meta');
meta.name = 'theme-color';
document.head.appendChild(meta);
}
meta.content = config.meta_theme_color;
}
}, []);
const fetchThemeConfig = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await axios.get(`${API_URL}/api/config/theme`);
const config = { ...DEFAULT_THEME, ...response.data };
// Merge colors if provided
if (response.data.colors) {
config.colors = { ...DEFAULT_THEME.colors, ...response.data.colors };
}
setThemeConfig(config);
applyThemeToDOM(config);
} catch (err) {
console.warn('Failed to fetch theme config, using defaults:', err.message);
setError(err.message);
// Apply default theme to DOM
applyThemeToDOM(DEFAULT_THEME);
} finally {
setLoading(false);
}
}, [applyThemeToDOM]);
// Fetch theme config on mount
useEffect(() => {
fetchThemeConfig();
}, [fetchThemeConfig]);
// Helper function to get logo URL with fallback
const getLogoUrl = useCallback(() => {
return themeConfig.logo_url || `${process.env.PUBLIC_URL}/loaf-logo.png`;
}, [themeConfig.logo_url]);
// Helper function to get favicon URL with fallback
const getFaviconUrl = useCallback(() => {
return themeConfig.favicon_url || `${process.env.PUBLIC_URL}/favicon.ico`;
}, [themeConfig.favicon_url]);
const value = {
// Theme configuration
themeConfig,
loading,
error,
// Convenience accessors
siteName: themeConfig.site_name,
siteShortName: themeConfig.site_short_name,
siteDescription: themeConfig.site_description,
colors: themeConfig.colors,
metaThemeColor: themeConfig.meta_theme_color,
// Helper functions
getLogoUrl,
getFaviconUrl,
// Actions
refreshTheme: fetchThemeConfig,
// Default theme for reference
DEFAULT_THEME
};
return (
<ThemeConfigContext.Provider value={value}>
{children}
</ThemeConfigContext.Provider>
);
};
export const useThemeConfig = () => {
const context = useContext(ThemeConfigContext);
if (context === undefined) {
throw new Error('useThemeConfig must be used within a ThemeConfigProvider');
}
return context;
};
export default ThemeConfigContext;

View File

@@ -0,0 +1,93 @@
import React, { createContext, useState, useContext, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import api from '../utils/api';
const UsersContext = createContext();
// Role definitions
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
const MEMBER_ROLES = ['member'];
export const UsersProvider = ({ children }) => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await api.get('/admin/users');
setUsers(response.data);
} catch (err) {
setError(err);
toast.error('Failed to fetch users');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
// Filtered views based on role
const staff = useMemo(
() => users.filter(user => STAFF_ROLES.includes(user.role)),
[users]
);
const members = useMemo(
() => users.filter(user => MEMBER_ROLES.includes(user.role)),
[users]
);
const allUsers = users;
// Update a single user in the local state (useful after edits)
const updateUser = useCallback((updatedUser) => {
setUsers(prev => prev.map(user =>
user.id === updatedUser.id ? updatedUser : user
));
}, []);
// Remove a user from local state
const removeUser = useCallback((userId) => {
setUsers(prev => prev.filter(user => user.id !== userId));
}, []);
// Add a user to local state
const addUser = useCallback((newUser) => {
setUsers(prev => [...prev, newUser]);
}, []);
return (
<UsersContext.Provider value={{
// All data
users: allUsers,
staff,
members,
// State
loading,
error,
// Actions
refetch: fetchUsers,
updateUser,
removeUser,
addUser,
}}>
{children}
</UsersContext.Provider>
);
};
// Base hook to access the context
export const useUsers = () => {
const context = useContext(UsersContext);
if (!context) {
throw new Error('useUsers must be used within a UsersProvider');
}
return context;
};
export default UsersContext;

View File

@@ -0,0 +1,106 @@
// src/hooks/use-member-tiers.js
import { useState, useEffect, useCallback } from 'react';
import api from '../utils/api';
import { DEFAULT_MEMBER_TIERS } from '../config/MemberTiers';
/**
* Hook for fetching and managing member tier configuration
* @param {Object} options
* @param {boolean} options.isAdmin - Whether to use admin endpoint (includes metadata)
* @returns {Object} Tier state and methods
*/
const useMemberTiers = ({ isAdmin = false } = {}) => {
const [tiers, setTiers] = useState(DEFAULT_MEMBER_TIERS);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const endpoint = isAdmin
? '/admin/settings/member-tiers'
: '/settings/member-tiers';
const fetchTiers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.get(endpoint);
const data = response.data?.tiers || response.data || DEFAULT_MEMBER_TIERS;
setTiers(data);
} catch (err) {
console.error('Failed to fetch member tiers:', err);
setError('Failed to load member tiers');
// Use defaults on error
setTiers(DEFAULT_MEMBER_TIERS);
} finally {
setLoading(false);
}
}, [endpoint]);
useEffect(() => {
fetchTiers();
}, [fetchTiers]);
/**
* Update tier configuration (admin only)
* @param {Array} newTiers - Updated tier array
* @returns {Promise<boolean>} Success status
*/
const updateTiers = useCallback(async (newTiers) => {
if (!isAdmin) {
console.error('updateTiers requires admin access');
return false;
}
try {
setSaving(true);
setError(null);
await api.put('/admin/settings/member-tiers', { tiers: newTiers });
setTiers(newTiers);
return true;
} catch (err) {
console.error('Failed to update member tiers:', err);
setError('Failed to save member tiers');
return false;
} finally {
setSaving(false);
}
}, [isAdmin]);
/**
* Reset tiers to defaults (superadmin only)
* @returns {Promise<boolean>} Success status
*/
const resetToDefaults = useCallback(async () => {
if (!isAdmin) {
console.error('resetToDefaults requires admin access');
return false;
}
try {
setSaving(true);
setError(null);
const response = await api.post('/admin/settings/member-tiers/reset');
const data = response.data?.tiers || response.data || DEFAULT_MEMBER_TIERS;
setTiers(data);
return true;
} catch (err) {
console.error('Failed to reset member tiers:', err);
setError('Failed to reset member tiers');
return false;
} finally {
setSaving(false);
}
}, [isAdmin]);
return {
tiers,
loading,
error,
saving,
fetchTiers,
updateTiers,
resetToDefaults,
};
};
export default useMemberTiers;

90
src/hooks/use-members.js Normal file
View File

@@ -0,0 +1,90 @@
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import api from '../utils/api';
const DEFAULT_SEARCH_FIELDS = ['first_name', 'last_name', 'email'];
/**
* Hook for fetching users from a custom endpoint (e.g., member-facing directory).
* For admin pages, use hooks from use-users.js instead which share a centralized context.
*/
const useMembers = ({
endpoint = '/admin/users',
initialFilter = 'active',
initialSearch = '',
filterKey = 'status',
allowedRoles = ['member'],
searchFields = DEFAULT_SEARCH_FIELDS,
fetchErrorMessage = 'Failed to fetch members',
searchAccessor,
transform,
onFetchError,
} = {}) => {
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [filterValue, setFilterValue] = useState(initialFilter);
const fetchMembers = useCallback(async () => {
try {
const response = await api.get(endpoint);
let filtered = response.data;
if (typeof transform === 'function') {
filtered = transform(filtered);
}
if (allowedRoles && allowedRoles.length) {
filtered = filtered.filter(user => allowedRoles.includes(user.role));
}
setUsers(filtered);
} catch (error) {
if (typeof onFetchError === 'function') {
onFetchError(error);
} else {
toast.error(fetchErrorMessage);
}
} finally {
setLoading(false);
}
}, [allowedRoles, endpoint, fetchErrorMessage, onFetchError, transform]);
useEffect(() => {
fetchMembers();
}, [fetchMembers]);
useEffect(() => {
let filtered = users;
if (filterValue && filterValue !== 'all') {
filtered = filtered.filter(user => user[filterKey] === filterValue);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user => {
const values = typeof searchAccessor === 'function'
? searchAccessor(user)
: searchFields.map(field => user?.[field]);
return values
.filter(Boolean)
.some(value => value.toString().toLowerCase().includes(query));
});
}
setFilteredUsers(filtered);
}, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
return {
users,
filteredUsers,
loading,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
fetchMembers,
};
};
export default useMembers;

171
src/hooks/use-users.js Normal file
View File

@@ -0,0 +1,171 @@
import { useState, useMemo } from 'react';
import { useUsers } from '../context/UsersContext';
const DEFAULT_SEARCH_FIELDS = ['first_name', 'last_name', 'email'];
/**
* Base hook that adds search and filter functionality to any user list
*/
const useFilteredUsers = ({
users,
initialFilter = 'all',
filterKey = 'status',
searchFields = DEFAULT_SEARCH_FIELDS,
searchAccessor,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [filterValue, setFilterValue] = useState(initialFilter);
const filteredUsers = useMemo(() => {
let filtered = users;
// Apply filter
if (filterValue && filterValue !== 'all') {
filtered = filtered.filter(user => user[filterKey] === filterValue);
}
// Apply search
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user => {
const values = typeof searchAccessor === 'function'
? searchAccessor(user)
: searchFields.map(field => user?.[field]);
return values
.filter(Boolean)
.some(value => value.toString().toLowerCase().includes(query));
});
}
return filtered;
}, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
return {
filteredUsers,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
};
};
/**
* Hook for staff users (admin, superadmin, finance roles)
*/
export const useStaff = ({
initialFilter = 'all',
filterKey = 'role',
searchFields = DEFAULT_SEARCH_FIELDS,
searchAccessor,
} = {}) => {
const { staff, loading, error, refetch, updateUser, removeUser } = useUsers();
const {
filteredUsers,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
} = useFilteredUsers({
users: staff,
initialFilter,
filterKey,
searchFields,
searchAccessor,
});
return {
users: staff,
filteredUsers,
loading,
error,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
refetch,
updateUser,
removeUser,
};
};
/**
* Hook for member users (non-admin roles)
*/
export const useMembers = ({
initialFilter = 'active',
filterKey = 'status',
searchFields = DEFAULT_SEARCH_FIELDS,
searchAccessor,
} = {}) => {
const { members, loading, error, refetch, updateUser, removeUser } = useUsers();
const {
filteredUsers,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
} = useFilteredUsers({
users: members,
initialFilter,
filterKey,
searchFields,
searchAccessor,
});
return {
users: members,
filteredUsers,
loading,
error,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
refetch,
updateUser,
removeUser,
};
};
/**
* Hook for all users (both staff and members)
*/
export const useAllUsers = ({
initialFilter = 'all',
filterKey = 'status',
searchFields = DEFAULT_SEARCH_FIELDS,
searchAccessor,
} = {}) => {
const { users, loading, error, refetch, updateUser, removeUser } = useUsers();
const {
filteredUsers,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
} = useFilteredUsers({
users,
initialFilter,
filterKey,
searchFields,
searchAccessor,
});
return {
users,
filteredUsers,
loading,
error,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
refetch,
updateUser,
removeUser,
};
};

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { ThemeProvider } from 'next-themes'; import { ThemeProvider } from 'next-themes';
import { ThemeConfigProvider } from './context/ThemeConfigContext';
import '@fontsource/fraunces/600.css'; import '@fontsource/fraunces/600.css';
import '@fontsource/dm-sans/400.css'; import '@fontsource/dm-sans/400.css';
import '@fontsource/dm-sans/700.css'; import '@fontsource/dm-sans/700.css';
@@ -16,7 +17,9 @@ root.render(
enableSystem={false} enableSystem={false}
storageKey="admin-theme" storageKey="admin-theme"
> >
<App /> <ThemeConfigProvider>
<App />
</ThemeConfigProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { Menu } from 'lucide-react';
import AdminSidebar from '../components/AdminSidebar'; import AdminSidebar from '../components/AdminSidebar';
import { UsersProvider } from '../context/UsersContext';
const AdminLayout = ({ children }) => { const AdminLayout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -46,29 +48,48 @@ const AdminLayout = ({ children }) => {
}; };
return ( return (
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}> <UsersProvider>
{/* Sidebar */} <div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
<AdminSidebar {/* Sidebar */}
isOpen={sidebarOpen} <AdminSidebar
onToggle={toggleSidebar} isOpen={sidebarOpen}
isMobile={isMobile} onToggle={toggleSidebar}
/> isMobile={isMobile}
{/* Mobile Overlay */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 transition-opacity"
onClick={closeSidebar}
/> />
)}
{/* Main Content Area */} {/* Mobile Overlay */}
<main className="flex-1 overflow-y-auto scrollbar-dashboard"> {isMobile && sidebarOpen && (
<div className="max-w-7xl mx-auto px-6 py-8"> <div
{children} className="fixed inset-0 bg-black/50 z-30 transition-opacity"
</div> onClick={closeSidebar}
</main> />
</div> )}
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto scrollbar-dashboard">
{isMobile && (
<div className="sticky top-0 z-20 bg-background/90 backdrop-blur border-b border-[var(--neutral-800)] px-4 py-3 flex items-center gap-3">
<button
onClick={toggleSidebar}
className="p-2 rounded-lg hover:bg-[var(--neutral-800)]/20 transition-colors"
aria-label={sidebarOpen ? 'Close sidebar' : 'Open sidebar'}
>
<Menu className="h-5 w-5 text-primary" />
</button>
<span
className="text-sm font-semibold text-primary"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Menu
</span>
</div>
)}
<div className="max-w-7xl mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
</UsersProvider>
); );
}; };

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import SettingsTabs from '../components/SettingsSidebar';
import { Settings } from 'lucide-react';
const SettingsLayout = () => {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Settings className="h-6 w-6" />
Settings
</h1>
<p className="text-muted-foreground mt-1">
Manage your platform configuration and preferences
</p>
</div>
{/* Tabs Navigation */}
<SettingsTabs />
{/* Content Area */}
<div className="min-w-0">
<Outlet />
</div>
</div>
);
};
export default SettingsLayout;

View File

@@ -387,7 +387,7 @@ const AcceptInvitation = () => {
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)} onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="John" placeholder="Jane"
/> />
{formErrors.first_name && ( {formErrors.first_name && (
<p className="text-sm text-red-500">{formErrors.first_name}</p> <p className="text-sm text-red-500">{formErrors.first_name}</p>

View File

@@ -7,8 +7,11 @@ import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge'; import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar'; import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter'; import MemberFooter from '../components/MemberFooter';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale } from 'lucide-react'; import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import TransactionHistory from '../components/TransactionHistory';
import MemberBadge from '@/components/MemberBadge';
import useMemberTiers from '../hooks/use-member-tiers'
const Dashboard = () => { const Dashboard = () => {
const { user, resendVerificationEmail, refreshUser } = useAuth(); const { user, resendVerificationEmail, refreshUser } = useAuth();
@@ -17,11 +20,16 @@ const Dashboard = () => {
const [resendLoading, setResendLoading] = useState(false); const [resendLoading, setResendLoading] = useState(false);
const [eventActivity, setEventActivity] = useState(null); const [eventActivity, setEventActivity] = useState(null);
const [activityLoading, setActivityLoading] = useState(true); const [activityLoading, setActivityLoading] = useState(true);
const [transactionsLoading, setTransactionsLoading] = useState(true);
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [activeTransactionTab, setActiveTransactionTab] = useState('all');
const joinedDate = user?.member_since || user?.created_at; const joinedDate = user?.member_since || user?.created_at;
const { tiers, loading: tiersLoading } = useMemberTiers();
useEffect(() => { useEffect(() => {
fetchUpcomingEvents(); fetchUpcomingEvents();
fetchEventActivity(); fetchEventActivity();
fetchTransactions();
}, []); }, []);
const fetchUpcomingEvents = async () => { const fetchUpcomingEvents = async () => {
@@ -47,6 +55,19 @@ const Dashboard = () => {
} }
}; };
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 handleResendVerification = async () => { const handleResendVerification = async () => {
setResendLoading(true); setResendLoading(true);
try { try {
@@ -71,6 +92,7 @@ const Dashboard = () => {
} }
}; };
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const statusConfig = { const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
@@ -111,6 +133,8 @@ const Dashboard = () => {
return messages[status] || ''; return messages[status] || '';
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Navbar /> <Navbar />
@@ -180,58 +204,81 @@ const Dashboard = () => {
</Card> </Card>
{/* Grid Layout */} {/* Grid Layout */}
<div className="grid lg:grid-cols-3 gap-8"> <div className="grid lg:grid-cols-2 gap-8">
{/* Quick Stats */} {/* Quick Stats */}
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card"> <div className='space-y-8'>
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Quick Info <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
</h3> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
<div className="space-y-4"> Quick Info
<div> </h3>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p> <div className="space-y-4">
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p> {/* member date and badge */}
</div> <div className='flex justify-between'>
<div>
<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-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-[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> <div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p> <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" }}> <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 {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
</p> </p>
</div> </div>
</> {!tiersLoading && (
)} <div className='lg:mr-10'>
</div> <MemberBadge memberSince={joinedDate} tiers={tiers} />
</Card> </div>
)}
</div>
{/* email */}
<div>
<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>
{/* role */}
<div>
<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>
</Card>
<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" }}>
Membership Info
</h3>
<div className="space-y-4">
{!user.subscription_end_date && !user.subscription_end_date && (
<div>No subscriptions yet</div>
)}
{user?.subscription_start_date && user?.subscription_end_date && (
<>
<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-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>
</>
)}
</div>
</Card>
</div>
{/* Upcoming Events */} {/* Upcoming Events */}
<Card className="lg:col-span-2 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card"> <Card className="lg:col-span-1 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"> <div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Upcoming Events My Event Activity
</h3> </h3>
<Link to="/events"> <Link to="/events">
<Button <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">
className="btn-lavender " <Calendar className="h-4 w-4 mr-2" />
data-testid="view-all-events-button" Browse Events
>
View All
</Button> </Button>
</Link> </Link>
</div> </div>
@@ -313,155 +360,18 @@ const Dashboard = () => {
</Card> </Card>
)} )}
{/* Event Activity Section */} {/* Transaction History Section */}
<div className="mt-12"> <div className="mt-8">
<div className="flex justify-between items-center mb-6"> <TransactionHistory
<h2 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> subscriptions={transactions.subscriptions}
My Event Activity donations={transactions.donations}
</h2> totalSubscriptionCents={transactions.total_subscription_amount_cents}
</div> totalDonationCents={transactions.total_donation_amount_cents}
loading={transactionsLoading}
{activityLoading ? ( isAdmin={false}
<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>
</div> </div>
<MemberFooter /> <MemberFooter />
</div> </div>

View File

@@ -2,9 +2,11 @@ import React from 'react';
import PublicNavbar from '../components/PublicNavbar'; import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter'; import PublicFooter from '../components/PublicFooter';
import { Card } from '../components/ui/card'; import { Card } from '../components/ui/card';
import { useThemeConfig } from '../context/ThemeConfigContext';
const MissionValues = () => { const MissionValues = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">

View File

@@ -721,17 +721,6 @@ const Profile = () => {
onOpenChange={setPasswordDialogOpen} 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> </div>
<MemberFooter /> <MemberFooter />
</div> </div>

View File

@@ -330,7 +330,7 @@ const AdminBylaws = () => {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent> <DialogContent className="overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{selectedBylaws ? 'Edit Bylaws' : 'Add Bylaws Version'} {selectedBylaws ? 'Edit Bylaws' : 'Add Bylaws Version'}

View File

@@ -60,7 +60,7 @@ const AdminDashboard = () => {
return ( return (
<> <>
<div className='flex justify-between items-center'> <div className='flex flex-col md:flex-row md:justify-between md:items-center'>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] 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 Admin Dashboard
@@ -69,9 +69,9 @@ const AdminDashboard = () => {
Manage users, events, and membership applications. Manage users, events, and membership applications.
</p> </p>
</div> </div>
<Link to={'/'}> <Link to={'/'} className=''>
<Button <Button
className="btn-lavender" className="btn-lavender mb-8 md:mb-0 "
> >
<Globe /> <Globe />
View Public Site View Public Site

View File

@@ -191,10 +191,9 @@ const AdminFinancials = () => {
<div className="space-y-4"> <div className="space-y-4">
{reports.map(report => ( {reports.map(report => (
<Card key={report.id} className="p-6"> <Card key={report.id} className="p-6">
<div className="flex items-center gap-6"> <div className="flex items-center gap-3">
<div className="bg-light-lavender p-4 rounded-xl min-w-[100px] text-center"> <div className="bg-light-lavender p-3 rounded-xl self-center">
<DollarSign className="h-6 w-6 mx-auto mb-1" /> <DollarSign className="size-8 " />
<div className="text-2xl font-bold">{report.year}</div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">

View File

@@ -0,0 +1,363 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../../components/ui/alert-dialog';
import { toast } from 'sonner';
import { useAuth } from '../../context/AuthContext';
import useMemberTiers from '../../hooks/use-member-tiers';
import { TIER_ICON_OPTIONS, BADGE_COLOR_PRESETS } from '../../config/MemberTiers';
import { getTierIcon } from '../../config/memberTierIcons';
import { Save, RotateCcw, Plus, Trash2, GripVertical, AlertTriangle, Users } from 'lucide-react';
const AdminMemberTiers = () => {
const { user } = useAuth();
const { tiers, loading, saving, updateTiers, resetToDefaults } = useMemberTiers({ isAdmin: true });
const [editedTiers, setEditedTiers] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const isSuperAdmin = user?.role === 'superadmin';
// Initialize edited tiers when tiers load
useEffect(() => {
if (tiers && tiers.length > 0) {
setEditedTiers(JSON.parse(JSON.stringify(tiers)));
setHasChanges(false);
}
}, [tiers]);
// Check for changes
useEffect(() => {
if (tiers && editedTiers.length > 0) {
const changed = JSON.stringify(tiers) !== JSON.stringify(editedTiers);
setHasChanges(changed);
}
}, [tiers, editedTiers]);
const handleTierChange = useCallback((index, field, value) => {
setEditedTiers(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
}, []);
const handleAddTier = useCallback(() => {
const newTier = {
id: `tier_${Date.now()}`,
label: 'New Tier',
minYears: editedTiers.length > 0
? Math.max(...editedTiers.map(t => t.maxYears || 0)) + 0.001
: 0,
maxYears: 999,
iconKey: 'star',
badgeClass: 'bg-gray-100 text-gray-800 border-gray-200',
};
setEditedTiers(prev => [...prev, newTier]);
}, [editedTiers]);
const handleRemoveTier = useCallback((index) => {
if (editedTiers.length <= 1) {
toast.error('You must have at least one tier');
return;
}
setEditedTiers(prev => prev.filter((_, i) => i !== index));
}, [editedTiers.length]);
const validateTiers = useCallback(() => {
for (let i = 0; i < editedTiers.length; i++) {
const tier = editedTiers[i];
if (!tier.label?.trim()) {
toast.error(`Tier ${i + 1} must have a label`);
return false;
}
if (tier.minYears < 0) {
toast.error(`Tier "${tier.label}" has invalid minimum years`);
return false;
}
if (tier.maxYears <= tier.minYears) {
toast.error(`Tier "${tier.label}" max years must be greater than min years`);
return false;
}
}
// Check for overlapping ranges
const sorted = [...editedTiers].sort((a, b) => a.minYears - b.minYears);
for (let i = 0; i < sorted.length - 1; i++) {
if (sorted[i].maxYears >= sorted[i + 1].minYears) {
toast.error(`Tier ranges overlap between "${sorted[i].label}" and "${sorted[i + 1].label}"`);
return false;
}
}
return true;
}, [editedTiers]);
const handleSave = async () => {
if (!validateTiers()) return;
const success = await updateTiers(editedTiers);
if (success) {
toast.success('Member tiers saved successfully');
} else {
toast.error('Failed to save member tiers');
}
};
const handleReset = async () => {
const success = await resetToDefaults();
if (success) {
toast.success('Member tiers reset to defaults');
setShowResetDialog(false);
} else {
toast.error('Failed to reset member tiers');
}
};
const handleDiscardChanges = () => {
setEditedTiers(JSON.parse(JSON.stringify(tiers)));
setHasChanges(false);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header and Actions */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<p className="text-muted-foreground">
Configure tier names, time ranges, and badges displayed in the members directory.
</p>
<div className="flex items-center gap-2">
{hasChanges && (
<Button variant="outline" onClick={handleDiscardChanges}>
Discard
</Button>
)}
{isSuperAdmin && (
<Button
variant="outline"
onClick={() => setShowResetDialog(true)}
className="text-destructive hover:text-destructive"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset
</Button>
)}
<Button onClick={handleSave} disabled={saving || !hasChanges}>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{/* Tier Cards */}
<div className="space-y-4">
{editedTiers.map((tier, index) => {
const IconComponent = getTierIcon(tier.iconKey);
return (
<Card key={tier.id} className="bg-background">
<CardContent className="pt-6">
<div className="flex flex-col lg:flex-row gap-6">
{/* Drag Handle & Remove */}
<div className="flex lg:flex-col items-center gap-2 lg:pt-6">
<GripVertical className="h-5 w-5 text-muted-foreground cursor-move" />
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTier(index)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={editedTiers.length <= 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Tier Configuration */}
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Label */}
<div className="space-y-2">
<Label htmlFor={`tier-label-${index}`}>Label</Label>
<Input
id={`tier-label-${index}`}
value={tier.label}
onChange={(e) => handleTierChange(index, 'label', e.target.value)}
placeholder="Tier Name"
/>
</div>
{/* Min Years */}
<div className="space-y-2">
<Label htmlFor={`tier-min-${index}`}>Min Years</Label>
<Input
id={`tier-min-${index}`}
type="number"
step="0.001"
min="0"
value={tier.minYears}
onChange={(e) => handleTierChange(index, 'minYears', parseFloat(e.target.value) || 0)}
/>
</div>
{/* Max Years */}
<div className="space-y-2">
<Label htmlFor={`tier-max-${index}`}>Max Years</Label>
<Input
id={`tier-max-${index}`}
type="number"
step="0.001"
min="0"
value={tier.maxYears}
onChange={(e) => handleTierChange(index, 'maxYears', parseFloat(e.target.value) || 0)}
/>
</div>
{/* Icon */}
<div className="space-y-2">
<Label htmlFor={`tier-icon-${index}`}>Icon</Label>
<Select
value={tier.iconKey}
onValueChange={(value) => handleTierChange(index, 'iconKey', value)}
>
<SelectTrigger id={`tier-icon-${index}`}>
<SelectValue placeholder="Select icon" />
</SelectTrigger>
<SelectContent>
{TIER_ICON_OPTIONS.map((option) => {
const OptionIcon = getTierIcon(option.key);
return (
<SelectItem key={option.key} value={option.key}>
<div className="flex items-center gap-2">
<OptionIcon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Badge Color */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`tier-badge-${index}`}>Badge Style</Label>
<Select
value={tier.badgeClass}
onValueChange={(value) => handleTierChange(index, 'badgeClass', value)}
>
<SelectTrigger id={`tier-badge-${index}`}>
<SelectValue placeholder="Select color" />
</SelectTrigger>
<SelectContent>
{BADGE_COLOR_PRESETS.map((preset) => (
<SelectItem key={preset.label} value={preset.badgeClass}>
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${preset.badgeClass}`} />
{preset.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Preview */}
<div className="space-y-2 md:col-span-2 flex flex-col">
<Label>Preview</Label>
<div className="flex-1 flex items-center">
<Badge className={`px-3 py-1.5 rounded-md text-sm flex items-center gap-2 border ${tier.badgeClass}`}>
<IconComponent className="h-4 w-4" />
{tier.label || 'Tier Name'}
</Badge>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Add Tier Button */}
<Button variant="outline" onClick={handleAddTier} className="w-full border-dashed">
<Plus className="h-4 w-4 mr-2" />
Add Tier
</Button>
{/* Info Card */}
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-5 w-5" />
How Member Tiers Work
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>
Member tiers are automatically assigned based on how long a member has been active.
The tier badge appears on member profiles and in the member directory.
</p>
<ul className="list-disc list-inside space-y-1">
<li>Tiers are matched based on membership duration in years</li>
<li>Each tier should have non-overlapping year ranges</li>
<li>The last tier typically uses a high max value (e.g., 999) to catch all long-term members</li>
</ul>
</CardContent>
</Card>
{/* Reset Confirmation Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Reset Tiers to Defaults?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all custom tier configurations and restore the default member tiers.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleReset}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Reset to Defaults
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default AdminMemberTiers;

View File

@@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { import {
@@ -18,19 +17,26 @@ import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircl
import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog'; import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog'; import InviteMemberDialog from '../../components/InviteMemberDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard'; import WordPressImportWizard from '../../components/WordPressImportWizard';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard'; import { StatCard } from '@/components/StatCard';
import { useMembers } from '../../hooks/use-users';
const AdminMembers = () => { const AdminMembers = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [users, setUsers] = useState([]); const {
const [filteredUsers, setFilteredUsers] = useState([]); users,
const [loading, setLoading] = useState(true); filteredUsers,
const [searchQuery, setSearchQuery] = useState(''); loading,
const [statusFilter, setStatusFilter] = useState('active'); searchQuery,
setSearchQuery,
filterValue: statusFilter,
setFilterValue: setStatusFilter,
refetch,
} = useMembers();
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null); const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
const [statusChanging, setStatusChanging] = useState(null); const [statusChanging, setStatusChanging] = useState(null);
@@ -41,53 +47,13 @@ const AdminMembers = () => {
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
useEffect(() => {
fetchMembers();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, statusFilter]);
const fetchMembers = async () => {
try {
const response = await api.get('/admin/users');
// Filter to only members
const members = response.data.filter(user => user.role === 'member');
setUsers(members);
} catch (error) {
toast.error('Failed to fetch members');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (statusFilter && statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const handleActivatePayment = (user) => { const handleActivatePayment = (user) => {
setSelectedUserForPayment(user); setSelectedUserForPayment(user);
setPaymentDialogOpen(true); setPaymentDialogOpen(true);
}; };
const handlePaymentSuccess = () => { const handlePaymentSuccess = () => {
fetchMembers(); // Refresh list refetch(); // Refresh list
}; };
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => { const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
@@ -108,7 +74,7 @@ const AdminMembers = () => {
try { try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus }); await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success('Member status updated successfully'); toast.success('Member status updated successfully');
fetchMembers(); // Refresh list refetch(); // Refresh list
} catch (error) { } catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update status'); toast.error(error.response?.data?.detail || 'Failed to update status');
} finally { } finally {
@@ -200,27 +166,6 @@ const AdminMembers = () => {
}; };
}; };
const getStatusBadge = (status) => {
const config = {
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 variant={statusConfig.variant} className={` px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
const getReminderInfo = (user) => { const getReminderInfo = (user) => {
const emailReminders = user.email_verification_reminders_sent || 0; const emailReminders = user.email_verification_reminders_sent || 0;
const eventReminders = user.event_attendance_reminders_sent || 0; const eventReminders = user.event_attendance_reminders_sent || 0;
@@ -244,7 +189,7 @@ const AdminMembers = () => {
return ( return (
<> <>
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-start mb-4"> <div className="flex flex-col md:flex-row justify-between items-start mb-4">
<div> <div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] 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" }}>
Members Management Members Management
@@ -253,7 +198,7 @@ const AdminMembers = () => {
Manage paying members and their subscriptions. Manage paying members and their subscriptions.
</p> </p>
</div> </div>
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap ">
{hasPermission('users.export') && ( {hasPermission('users.export') && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -325,15 +270,6 @@ const AdminMembers = () => {
Quick Overview Quick Overview
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4"> <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 <StatCard
title="Active" title="Active"
value={users.filter(u => u.status === 'active').length} value={users.filter(u => u.status === 'active').length}
@@ -355,9 +291,13 @@ const AdminMembers = () => {
iconBgClass=" text-brand-pink" iconBgClass=" text-brand-pink"
dataTestId="stat-inactive-members" dataTestId="stat-inactive-members"
/> />
<StatCard
title="Total Members"
value={users.length}
icon={Users}
iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
dataTestId="stat-total-members"
/>
</div> </div>
</div> </div>
@@ -401,7 +341,8 @@ const AdminMembers = () => {
) : filteredUsers.length > 0 ? ( ) : filteredUsers.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{filteredUsers.map((user) => { {filteredUsers.map((user) => {
const joinedDate = user.member_since || user.created_at; const joinedDate = user.created_at;
const memberDate = user.member_since;
return ( return (
<Card <Card
key={user.id} key={user.id}
@@ -421,12 +362,13 @@ const AdminMembers = () => {
<h3 className="text-xl font-semibold text-[var(--purple-ink)] " 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} {user.first_name} {user.last_name}
</h3> </h3>
{getStatusBadge(user.status)} <StatusBadge status={user.status} />
</div> </div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple dark:text-brand-lavender " 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>Email: {user.email}</p>
<p>Phone: {user.phone}</p> <p>Phone: {user.phone}</p>
<p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p> <p>Registered: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
<p>Member Since: {memberDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
{user.referred_by_member_name && ( {user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p> <p>Referred by: {user.referred_by_member_name}</p>
)} )}
@@ -578,19 +520,19 @@ const AdminMembers = () => {
<CreateMemberDialog <CreateMemberDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
onSuccess={fetchMembers} onSuccess={refetch}
/> />
<InviteStaffDialog <InviteMemberDialog
open={inviteDialogOpen} open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen} onOpenChange={setInviteDialogOpen}
onSuccess={fetchMembers} onSuccess={refetch}
/> />
<WordPressImportWizard <WordPressImportWizard
open={importDialogOpen} open={importDialogOpen}
onOpenChange={setImportDialogOpen} onOpenChange={setImportDialogOpen}
onSuccess={fetchMembers} onSuccess={refetch}
/> />
</> </>
); );

View File

@@ -223,10 +223,13 @@ const AdminNewsletters = () => {
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
{year} {year}
</h2> </h2>
<div className="grid gap-4"> <div className="grid gap-3">
{groupedNewsletters[year].map(newsletter => ( {groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6"> <Card key={newsletter.id} className="p-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between ">
<div className="bg-light-lavender p-3 mr-4 rounded-xl self-center">
<FileText className="size-8 " />
</div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
{newsletter.title} {newsletter.title}
@@ -287,7 +290,7 @@ const AdminNewsletters = () => {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{selectedNewsletter ? 'Edit Newsletter' : 'Add Newsletter'} {selectedNewsletter ? 'Edit Newsletter' : 'Add Newsletter'}

View File

@@ -23,6 +23,7 @@ import {
Search, Search,
DollarSign DollarSign
} from 'lucide-react'; } from 'lucide-react';
import StatusBadge from '@/components/StatusBadge';
const AdminPlans = () => { const AdminPlans = () => {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -236,7 +237,7 @@ const AdminPlans = () => {
{plan.active ? 'Active' : 'Inactive'} {plan.active ? 'Active' : 'Inactive'}
</Badge> </Badge>
{plan.subscriber_count > 0 && ( {plan.subscriber_count > 0 && (
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]"> <Badge className="bg-[var(--neutral-800)] hover:text-white text-[var(--purple-ink)]">
<Users className="h-3 w-3 mr-1" /> <Users className="h-3 w-3 mr-1" />
{plan.subscriber_count} {plan.subscriber_count}
</Badge> </Badge>

View File

@@ -39,6 +39,7 @@ const AdminRoles = () => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showPermissionsModal, setShowPermissionsModal] = useState(false); const [showPermissionsModal, setShowPermissionsModal] = useState(false);
const [expandedModules, setExpandedModules] = useState({}); const [expandedModules, setExpandedModules] = useState({});
const [savingPermissions, setSavingPermissions] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
code: '', code: '',
name: '', name: '',
@@ -46,6 +47,15 @@ const AdminRoles = () => {
permissions: [] permissions: []
}); });
const formatRoleSlug = (value) => (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/_+/g, '-')
.replace(/^_+|_+$/g, '')
);
useEffect(() => { useEffect(() => {
fetchRoles(); fetchRoles();
fetchPermissions(); fetchPermissions();
@@ -133,6 +143,7 @@ const AdminRoles = () => {
}; };
const handleSavePermissions = async () => { const handleSavePermissions = async () => {
setSavingPermissions(true);
try { try {
await api.put(`/admin/roles/${selectedRole.id}/permissions`, { await api.put(`/admin/roles/${selectedRole.id}/permissions`, {
permission_codes: selectedPermissions permission_codes: selectedPermissions
@@ -142,6 +153,8 @@ const AdminRoles = () => {
fetchRoles(); fetchRoles();
} catch (error) { } catch (error) {
toast.error('Failed to update permissions'); toast.error('Failed to update permissions');
} finally {
setSavingPermissions(false);
} }
}; };
@@ -155,6 +168,14 @@ const AdminRoles = () => {
}); });
}; };
const addPermissions = (permissionCodes) => {
setSelectedPermissions(prev => [...new Set([...prev, ...permissionCodes])]);
};
const removePermissions = (permissionCodes) => {
setSelectedPermissions(prev => prev.filter(code => !permissionCodes.includes(code)));
};
const toggleModule = (module) => { const toggleModule = (module) => {
setExpandedModules(prev => ({ setExpandedModules(prev => ({
...prev, ...prev,
@@ -184,16 +205,13 @@ const AdminRoles = () => {
const groupedPermissions = groupPermissionsByModule(); const groupedPermissions = groupPermissionsByModule();
return ( return (
<div className="space-y-6 "> <div className="space-y-6">
{/* Header */} {/* Action Bar */}
<div className="flex justify-between items-center "> <div className="flex justify-between items-center">
<div> <p className="text-muted-foreground">
<h1 className="text-3xl font-bold text-[var(--purple-ink)]">Role Management</h1> Create and manage custom roles with specific permissions
<p className="text-gray-600 mt-1"> </p>
Create and manage custom roles with specific permissions <Button className="btn-lavender" onClick={() => setShowCreateModal(true)}>
</p>
</div>
<Button className="btn-lavender " onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Create Role Create Role
</Button> </Button>
@@ -282,8 +300,28 @@ const AdminRoles = () => {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label>Role Code *</Label> <Label>Role Name *</Label>
<Input
placeholder="e.g., Content Editor, Finance Manager"
value={formData.name}
onChange={(e) => {
const nextName = e.target.value;
setFormData(prev => {
const prevAuto = formatRoleSlug(prev.name);
const isAuto = !prev.code || prev.code === prevAuto;
return {
...prev,
name: nextName,
code: isAuto ? formatRoleSlug(nextName) : prev.code
};
});
}}
/>
</div>
<div>
<Label>Role Slug *</Label>
<Input <Input
placeholder="e.g., content_editor, finance_manager" placeholder="e.g., content_editor, finance_manager"
value={formData.code} value={formData.code}
@@ -294,15 +332,6 @@ const AdminRoles = () => {
</p> </p>
</div> </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> <div>
<Label>Description</Label> <Label>Description</Label>
<Textarea <Textarea
@@ -318,44 +347,83 @@ const AdminRoles = () => {
Select permissions for this role. You can also add permissions later. Select permissions for this role. You can also add permissions later.
</p> </p>
<div className="border rounded-lg p-4 max-h-64 overflow-y-auto scrollbar-dashboard"> <div className="border rounded-lg p-4 max-h-64 overflow-y-auto scrollbar-dashboard">
{Object.entries(groupedPermissions).map(([module, perms]) => ( {Object.entries(groupedPermissions).map(([module, perms]) => {
<div key={module} className="mb-4"> const moduleCodes = perms.map(perm => perm.code);
<button const selectedCount = moduleCodes.filter(code => formData.permissions.includes(code)).length;
onClick={() => toggleModule(module)} const hasPermissions = moduleCodes.length > 0;
className="flex items-center w-full text-left font-medium mb-2 hover:text-blue-600" const isAllSelected = hasPermissions && selectedCount === moduleCodes.length;
> const isNoneSelected = selectedCount === 0;
{expandedModules[module] ? (
<ChevronUp className="w-4 h-4 mr-1" /> return (
) : ( <div key={module} className="mb-4">
<ChevronDown className="w-4 h-4 mr-1" /> <div className="flex items-center justify-between mb-2">
)} <button
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length}) type="button"
</button> onClick={() => toggleModule(module)}
{expandedModules[module] && ( className="flex items-center text-left font-medium hover:text-blue-600"
<div className="space-y-2 ml-5"> >
{perms.map(perm => ( {expandedModules[module] ? (
<div key={perm.code} className="flex items-center"> <ChevronUp className="w-4 h-4 mr-1" />
<Checkbox ) : (
checked={formData.permissions.includes(perm.code)} <ChevronDown className="w-4 h-4 mr-1" />
onCheckedChange={() => { )}
setFormData(prev => ({ {module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
...prev, </button>
permissions: prev.permissions.includes(perm.code) <div className="flex items-center gap-3">
? prev.permissions.filter(p => p !== perm.code) <button
: [...prev.permissions, perm.code] type="button"
})); onClick={() => {
}} setFormData(prev => ({
/> ...prev,
<label className="ml-2 text-sm"> permissions: [...new Set([...prev.permissions, ...moduleCodes])]
<span className="font-medium">{perm.name}</span> }));
<span className="text-gray-500 ml-2">({perm.code})</span> }}
</label> disabled={!hasPermissions || isAllSelected}
</div> className="text-xs font-medium text-gray-500 hover:text-brand-purple disabled:opacity-50 disabled:cursor-not-allowed"
))} >
Select all
</button>
<button
type="button"
onClick={() => {
setFormData(prev => ({
...prev,
permissions: prev.permissions.filter(code => !moduleCodes.includes(code))
}));
}}
disabled={!hasPermissions || isNoneSelected}
className="text-xs font-medium text-gray-500 hover:text-brand-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
Deselect all
</button>
</div>
</div> </div>
)} {expandedModules[module] && (
</div> <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> </div>
</div> </div>
@@ -382,10 +450,6 @@ const AdminRoles = () => {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div>
<Label>Role Code</Label>
<Input value={selectedRole?.code || ''} disabled />
</div>
<div> <div>
<Label>Role Name *</Label> <Label>Role Name *</Label>
@@ -395,6 +459,11 @@ const AdminRoles = () => {
/> />
</div> </div>
<div>
<Label>Role Slug</Label>
<Input value={selectedRole?.code || ''} disabled />
</div>
<div> <div>
<Label>Description</Label> <Label>Description</Label>
<Textarea <Textarea
@@ -426,48 +495,77 @@ const AdminRoles = () => {
</DialogHeader> </DialogHeader>
<div className="border rounded-lg p-4"> <div className="border rounded-lg p-4">
{Object.entries(groupedPermissions).map(([module, perms]) => ( {Object.entries(groupedPermissions).map(([module, perms]) => {
<div key={module} className="mb-6"> const moduleCodes = perms.map(perm => perm.code);
<button const selectedCount = moduleCodes.filter(code => selectedPermissions.includes(code)).length;
onClick={() => toggleModule(module)} const hasPermissions = moduleCodes.length > 0;
className="flex items-center w-full text-left font-medium text-lg mb-3 hover:text-blue-600" const isAllSelected = hasPermissions && selectedCount === moduleCodes.length;
> const isNoneSelected = selectedCount === 0;
{expandedModules[module] ? (
<ChevronUp className="w-5 h-5 mr-2" /> return (
) : ( <div key={module} className="mb-6">
<ChevronDown className="w-5 h-5 mr-2" /> <div className="flex items-center justify-between mb-3">
)} <button
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length}) type="button"
</button> onClick={() => toggleModule(module)}
{expandedModules[module] && ( className="flex items-center text-left font-medium text-lg hover:text-blue-600"
<div className="space-y-3 ml-7"> >
{perms.map(perm => ( {expandedModules[module] ? (
<div key={perm.code} className="flex items-start"> <ChevronUp className="w-5 h-5 mr-2" />
<Checkbox ) : (
checked={selectedPermissions.includes(perm.code)} <ChevronDown className="w-5 h-5 mr-2" />
onCheckedChange={() => togglePermission(perm.code)} )}
/> {module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
<div className="ml-3"> </button>
<label className="font-medium text-sm"> <div className="flex items-center gap-3">
{perm.name} <button
</label> type="button"
<p className="text-xs text-gray-500">{perm.description}</p> onClick={() => addPermissions(moduleCodes)}
<span className="text-xs text-gray-400">{perm.code}</span> disabled={!hasPermissions || isAllSelected}
</div> className="text-xs font-medium text-gray-500 hover:text-brand-purple disabled:opacity-50 disabled:cursor-not-allowed"
</div> >
))} Select all
</button>
<button
type="button"
onClick={() => removePermissions(moduleCodes)}
disabled={!hasPermissions || isNoneSelected}
className="text-xs font-medium text-gray-500 hover:text-brand-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
Deselect all
</button>
</div>
</div> </div>
)} {expandedModules[module] && (
</div> <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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowPermissionsModal(false)}> <Button variant="outline" onClick={() => setShowPermissionsModal(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSavePermissions}> <Button onClick={handleSavePermissions} disabled={savingPermissions}>
Save Permissions {savingPermissions ? 'Saving...' : 'Save Permissions'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -491,6 +589,15 @@ const AdminRoles = () => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{savingPermissions && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-lg px-6 py-5 text-center">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-4 border-[var(--neutral-800)] border-t-transparent" />
<p className="mt-4 text-sm font-medium text-gray-700">Saving permissions...</p>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -126,18 +126,7 @@ export default function AdminSettings() {
} }
return ( return (
<div className="container mx-auto p-6 max-w-4xl"> <div className="space-y-6">
{/* 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 */} {/* Stripe Integration Card */}
<Card className="border-2 border-[var(--lavender-200)] shadow-sm"> <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)]"> <CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]">

View File

@@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
@@ -12,72 +11,39 @@ import CreateStaffDialog from '../../components/CreateStaffDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog'; import InviteStaffDialog from '../../components/InviteStaffDialog';
import PendingInvitationsTable from '../../components/PendingInvitationsTable'; import PendingInvitationsTable from '../../components/PendingInvitationsTable';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX } from 'lucide-react'; import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX, ShieldIcon } from 'lucide-react';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { CircleMinus, CreditCard, Users } from 'lucide-react';
import { useStaff } from '../../hooks/use-users';
const AdminStaff = () => { const AdminStaff = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission, user } = useAuth(); const { hasPermission, user } = useAuth();
const [users, setUsers] = useState([]); const {
const [filteredUsers, setFilteredUsers] = useState([]); users,
const [loading, setLoading] = useState(true); filteredUsers,
const [searchQuery, setSearchQuery] = useState(''); loading,
const [roleFilter, setRoleFilter] = useState('all'); searchQuery,
setSearchQuery,
filterValue: roleFilter,
setFilterValue: setRoleFilter,
refetch,
} = useStaff({
initialFilter: 'all',
filterKey: 'role',
});
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState('staff-list'); const [activeTab, setActiveTab] = useState('staff-list');
// Staff roles (non-guest, non-member) - includes all admin-type roles
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
useEffect(() => {
fetchStaff();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, roleFilter]);
const fetchStaff = async () => {
try {
const response = await api.get('/admin/users');
// Filter to only staff roles
const staffUsers = response.data.filter(user =>
STAFF_ROLES.includes(user.role)
);
setUsers(staffUsers);
} catch (error) {
toast.error('Failed to fetch staff');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (roleFilter && roleFilter !== 'all') {
filtered = filtered.filter(user => user.role === roleFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const handleToggleStatus = async (userId, currentStatus) => { const handleToggleStatus = async (userId, currentStatus) => {
const newStatus = currentStatus === 'active' ? 'inactive' : 'active'; const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
try { try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus }); await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`); toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
fetchStaff(); // Refresh list refetch(); // Refresh list
} catch (error) { } catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update user status'); toast.error(error.response?.data?.detail || 'Failed to update user status');
} }
@@ -91,48 +57,19 @@ const AdminStaff = () => {
try { try {
await api.delete(`/admin/users/${userId}`); await api.delete(`/admin/users/${userId}`);
toast.success('User deleted successfully'); toast.success('User deleted successfully');
fetchStaff(); // Refresh list refetch(); // Refresh list
} catch (error) { } catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete user'); toast.error(error.response?.data?.detail || 'Failed to delete user');
} }
}; };
const getRoleBadge = (role) => {
const config = {
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 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>
);
};
const getStatusBadge = (status) => {
const config = {
active: { label: 'Active', variant: 'green' },
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white ' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge variant={statusConfig.variant} className={` px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
return ( return (
<> <>
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-start mb-4"> <div className="flex flex-col md:flex-row justify-between items-start mb-4">
<div> <div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] 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" }}>
Staff Management Staff Management
@@ -141,7 +78,8 @@ const AdminStaff = () => {
Manage internal team members and their roles. Manage internal team members and their roles.
</p> </p>
</div> </div>
<div className="flex gap-3">
<div className="flex gap-3 ">
{hasPermission('users.create') && ( {hasPermission('users.create') && (
<Button <Button
onClick={() => setInviteDialogOpen(true)} onClick={() => setInviteDialogOpen(true)}
@@ -165,42 +103,52 @@ const AdminStaff = () => {
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8"> <div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Staff</p> Quick Overview
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> </div>
{users.length} <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
</p> <StatCard
</Card> title="Total Staff"
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> //TODO: refractor codebase to have a central admin and user roles config - when user adds roles, they should be added to the config
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Admins</p> value={users.filter(u => ['admin', 'superadmin', 'finance', 'staff', 'media'].includes(u.role)).length}
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> icon={Users}
{users.filter(u => ['admin', 'superadmin'].includes(u.role)).length} iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
</p> dataTestId="stat-total-members"
</Card> />
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <StatCard
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Moderators</p> title="Admins"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={users.filter(u => ['admin', 'superadmin'].includes(u.role)).length}
{users.filter(u => u.role === 'moderator').length} icon={Shield}
</p> iconBgClass="text-[var(--green-light)]"
</Card> dataTestId="stat-active-members"
<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> <StatCard
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> title="Finance Managers"
{users.filter(u => u.status === 'active').length} value={users.filter(u => u.role === 'finance').length}
</p> icon={CreditCard}
</Card> iconBgClass="text-brand-light-orange"
dataTestId="stat-payment-pending-members"
/>
<StatCard
title="Inactive"
value={users.filter(u => ['admin', 'superadmin'].includes(u.role)).length && users.filter(u => u.status !== 'inactive').length}
icon={CircleMinus}
iconBgClass=" text-brand-pink"
dataTestId="stat-inactive-members"
/>
</div>
</div> </div>
{/* Tabs */} {/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-8"> <Tabs value={activeTab} onValueChange={setActiveTab} className="mb-8">
<TabsList className="grid w-full grid-cols-2 mb-8"> <TabsList className="grid w-full grid-cols-2 mb-8 ">
<TabsTrigger value="staff-list" className="text-lg py-3"> <TabsTrigger value="staff-list" className="text-sm sm:text-md md:text-lg py-3">
<UserCog className="h-5 w-5 mr-2" /> <UserCog className="h-5 w-5 mr-2 hidden md:inline" />
Staff Members Staff Members
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="pending-invitations" className="text-lg py-3 "> <TabsTrigger value="pending-invitations" className="text-sm sm:text-md md:text-lg py-3 ">
<Mail className="h-5 w-5 mr-2" /> <Mail className="h-5 w-5 mr-2 hidden md:inline" />
Pending Invitations Pending Invitations
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -250,79 +198,79 @@ const AdminStaff = () => {
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] 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}`} data-testid={`staff-card-${user.id}`}
> >
<div className="flex justify-between items-start flex-wrap gap-4"> <div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1"> <div className="flex items-start gap-4 flex-1">
{/* Avatar */} {/* Avatar */}
<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"> <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]} {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-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</h3>
{getRoleBadge(user.role)}
{getStatusBadge(user.status)}
</div> </div>
<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> {/* Info */}
<p>Phone: {user.phone}</p> <div className="flex-1 min-w-0">
<p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p> <div className="flex items-center gap-3 mb-2 flex-wrap">
{user.last_login && ( <h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p> {user.first_name} {user.last_name}
)} </h3>
<StatusBadge status={user.role} />
<StatusBadge status={user.status} />
</div>
<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: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
{user.last_login && (
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
)}
</div>
</div> </div>
</div> </div>
</div>
{/* Actions */} {/* Actions */}
<div className="flex gap-2 flex-wrap"> <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 <Button
onClick={() => handleToggleStatus(user.id, user.status)} onClick={() => navigate(`/admin/users/${user.id}`)}
variant="outline" variant="outline"
className={`border-2 rounded-full px-4 py-2 ${user.status === 'active' className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-4 py-2"
? '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' ? ( <Edit className="h-4 w-4 mr-2" />
<> Manage
<UserX className="h-4 w-4 mr-2" />
Deactivate
</>
) : (
<>
<UserCheck className="h-4 w-4 mr-2" />
Activate
</>
)}
</Button> </Button>
)}
{hasPermission('users.delete') && ( {hasPermission('users.status') && (
<Button <Button
onClick={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)} onClick={() => handleToggleStatus(user.id, user.status)}
variant="outline" 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" 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'
<Trash2 className="h-4 w-4 mr-2" /> : 'border-green-500 text-green-600 hover:bg-green-50 hover:dark:bg-green-600/10'
Delete }`}
</Button> >
)} {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> </div>
</div>
</Card> </Card>
); );
})} })}
@@ -351,7 +299,7 @@ const AdminStaff = () => {
<CreateStaffDialog <CreateStaffDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
onSuccess={fetchStaff} onSuccess={refetch}
/> />
<InviteStaffDialog <InviteStaffDialog

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,743 @@
import React, { useEffect, useState, useCallback } from 'react';
import api from '../../utils/api';
import { useAuth } from '../../context/AuthContext';
import { useThemeConfig } from '../../context/ThemeConfigContext';
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 { Textarea } from '../../components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../../components/ui/alert-dialog';
import { toast } from 'sonner';
import { Palette, Upload, Trash2, RotateCcw, Save, Image, Globe, AlertTriangle } from 'lucide-react';
const AdminTheme = () => {
const { user } = useAuth();
const { refreshTheme, DEFAULT_THEME } = useThemeConfig();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingFavicon, setUploadingFavicon] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const [themeData, setThemeData] = useState({
site_name: '',
site_short_name: '',
site_description: '',
logo_url: null,
favicon_url: null,
colors: {
primary: '280 47% 27%',
primary_foreground: '0 0% 100%',
accent: '24 86% 55%',
brand_purple: '256 35% 47%',
brand_orange: '24 86% 55%',
brand_lavender: '262 46% 80%'
},
meta_theme_color: '#664fa3'
});
const [originalData, setOriginalData] = useState(null);
const [metadata, setMetadata] = useState({
is_default: true,
updated_at: null,
updated_by: null
});
const isSuperAdmin = user?.role === 'superadmin';
const fetchThemeSettings = useCallback(async () => {
try {
setLoading(true);
const response = await api.get('/admin/settings/theme');
const { config, is_default, updated_at, updated_by } = response.data;
setThemeData(config);
setOriginalData(config);
setMetadata({ is_default, updated_at, updated_by });
} catch (error) {
toast.error('Failed to fetch theme settings');
console.error('Fetch theme error:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchThemeSettings();
}, [fetchThemeSettings]);
const handleInputChange = (field, value) => {
setThemeData(prev => ({
...prev,
[field]: value
}));
};
const handleColorChange = (colorKey, value) => {
setThemeData(prev => ({
...prev,
colors: {
...prev.colors,
[colorKey]: value
}
}));
};
const handleSaveSettings = async () => {
try {
setSaving(true);
// Build update payload with only changed fields
const payload = {};
if (themeData.site_name !== originalData?.site_name) {
payload.site_name = themeData.site_name;
}
if (themeData.site_short_name !== originalData?.site_short_name) {
payload.site_short_name = themeData.site_short_name;
}
if (themeData.site_description !== originalData?.site_description) {
payload.site_description = themeData.site_description;
}
if (JSON.stringify(themeData.colors) !== JSON.stringify(originalData?.colors)) {
payload.colors = themeData.colors;
}
if (themeData.meta_theme_color !== originalData?.meta_theme_color) {
payload.meta_theme_color = themeData.meta_theme_color;
}
if (Object.keys(payload).length === 0) {
toast.info('No changes to save');
return;
}
await api.put('/admin/settings/theme', payload);
toast.success('Theme settings saved successfully');
// Refresh theme context and re-fetch settings
await refreshTheme();
await fetchThemeSettings();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save theme settings');
console.error('Save theme error:', error);
} finally {
setSaving(false);
}
};
const handleLogoUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
if (!allowedTypes.includes(file.type)) {
toast.error('Invalid file type. Please upload PNG, JPEG, WebP, or SVG.');
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('File too large. Maximum size is 5MB.');
return;
}
try {
setUploadingLogo(true);
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/admin/settings/theme/logo', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setThemeData(prev => ({
...prev,
logo_url: response.data.logo_url
}));
toast.success('Logo uploaded successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to upload logo');
console.error('Upload logo error:', error);
} finally {
setUploadingLogo(false);
// Reset the input
event.target.value = '';
}
};
const handleFaviconUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml'];
if (!allowedTypes.includes(file.type)) {
toast.error('Invalid file type. Please upload ICO, PNG, or SVG.');
return;
}
// Validate file size (1MB)
if (file.size > 1 * 1024 * 1024) {
toast.error('File too large. Maximum size is 1MB.');
return;
}
try {
setUploadingFavicon(true);
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/admin/settings/theme/favicon', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setThemeData(prev => ({
...prev,
favicon_url: response.data.favicon_url
}));
toast.success('Favicon uploaded successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to upload favicon');
console.error('Upload favicon error:', error);
} finally {
setUploadingFavicon(false);
// Reset the input
event.target.value = '';
}
};
const handleDeleteLogo = async () => {
try {
await api.delete('/admin/settings/theme/logo');
setThemeData(prev => ({
...prev,
logo_url: null
}));
toast.success('Logo deleted successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete logo');
}
};
const handleDeleteFavicon = async () => {
try {
await api.delete('/admin/settings/theme/favicon');
setThemeData(prev => ({
...prev,
favicon_url: null
}));
toast.success('Favicon deleted successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete favicon');
}
};
const handleResetToDefaults = async () => {
try {
await api.post('/admin/settings/theme/reset');
toast.success('Theme reset to defaults');
setShowResetDialog(false);
await refreshTheme();
await fetchThemeSettings();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to reset theme');
}
};
// Convert HSL string to approximate hex for color picker
const hslToHex = (hslString) => {
if (!hslString) return '#000000';
const parts = hslString.split(' ');
if (parts.length !== 3) return '#000000';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1]) / 100;
const l = parseFloat(parts[2]) / 100;
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
const toHex = (x) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Convert hex to HSL string
const hexToHsl = (hex) => {
if (!hex) return '0 0% 0%';
// Remove # if present
hex = hex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
default: h = 0;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
const colorFields = [
{ key: 'primary', label: 'Primary Color', description: 'Main brand color used for buttons and highlights' },
{ key: 'primary_foreground', label: 'Primary Foreground', description: 'Text color on primary backgrounds' },
{ key: 'accent', label: 'Accent Color', description: 'Secondary highlight color' },
{ key: 'brand_purple', label: 'Brand Purple', description: 'Purple brand color' },
{ key: 'brand_orange', label: 'Brand Orange', description: 'Orange brand color' },
{ key: 'brand_lavender', label: 'Brand Lavender', description: 'Lavender brand color' }
];
return (
<div className="space-y-6">
{/* Action Buttons */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
Customize the appearance of your membership site
</p>
<div className="flex items-center gap-2">
{isSuperAdmin && (
<Button
variant="outline"
onClick={() => setShowResetDialog(true)}
className="text-destructive hover:text-destructive"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</Button>
)}
<Button onClick={handleSaveSettings} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{/* Metadata Banner */}
{!metadata.is_default && metadata.updated_at && (
<div className="bg-muted/50 border rounded-lg px-4 py-3 text-sm text-muted-foreground">
Last updated {new Date(metadata.updated_at).toLocaleDateString()}
{metadata.updated_by && ` by ${metadata.updated_by}`}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Branding Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Branding
</CardTitle>
<CardDescription>
Configure your site name and brand identity
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Site Name */}
<div className="space-y-2">
<Label htmlFor="site_name">Site Name</Label>
<Input
id="site_name"
value={themeData.site_name}
onChange={(e) => handleInputChange('site_name', e.target.value)}
placeholder="LOAF - Lesbians Over Age Fifty"
maxLength={200}
/>
<p className="text-xs text-muted-foreground">
Displayed in the browser title and navigation
</p>
</div>
{/* Short Name */}
<div className="space-y-2">
<Label htmlFor="site_short_name">Short Name</Label>
<Input
id="site_short_name"
value={themeData.site_short_name}
onChange={(e) => handleInputChange('site_short_name', e.target.value)}
placeholder="LOAF"
maxLength={50}
/>
<p className="text-xs text-muted-foreground">
Used for PWA home screen icon label
</p>
</div>
{/* Site Description */}
<div className="space-y-2">
<Label htmlFor="site_description">Site Description</Label>
<Textarea
id="site_description"
value={themeData.site_description}
onChange={(e) => handleInputChange('site_description', e.target.value)}
placeholder="A community organization for lesbians over age fifty..."
maxLength={500}
rows={3}
/>
<p className="text-xs text-muted-foreground">
Used for SEO meta description tag ({themeData.site_description?.length || 0}/500)
</p>
</div>
{/* Meta Theme Color */}
<div className="space-y-2">
<Label htmlFor="meta_theme_color">Browser Theme Color</Label>
<div className="flex items-center gap-2">
<input
type="color"
id="meta_theme_color"
value={themeData.meta_theme_color}
onChange={(e) => handleInputChange('meta_theme_color', e.target.value)}
className="h-10 w-14 rounded border cursor-pointer"
/>
<Input
value={themeData.meta_theme_color}
onChange={(e) => handleInputChange('meta_theme_color', e.target.value)}
placeholder="#664fa3"
className="flex-1"
maxLength={7}
/>
</div>
<p className="text-xs text-muted-foreground">
Color shown in mobile browser address bar (PWA)
</p>
</div>
</CardContent>
</Card>
{/* Logo & Favicon Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Image className="h-5 w-5" />
Logo & Favicon
</CardTitle>
<CardDescription>
Upload your organization's logo and favicon
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Logo Upload */}
<div className="space-y-3">
<Label>Logo</Label>
<div className="flex items-start gap-4">
<div className="w-24 h-24 bg-muted rounded-lg flex items-center justify-center overflow-hidden border">
{themeData.logo_url ? (
<img
src={themeData.logo_url}
alt="Logo"
className="w-full h-full object-contain"
/>
) : (
<Image className="h-10 w-10 text-muted-foreground" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
onChange={handleLogoUpload}
className="hidden"
disabled={uploadingLogo}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingLogo}
asChild
>
<span>
<Upload className="h-4 w-4 mr-2" />
{uploadingLogo ? 'Uploading...' : 'Upload'}
</span>
</Button>
</label>
{themeData.logo_url && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDeleteLogo}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPEG, WebP, or SVG. Max 5MB.
</p>
</div>
</div>
</div>
{/* Favicon Upload */}
<div className="space-y-3">
<Label>Favicon</Label>
<div className="flex items-start gap-4">
<div className="w-16 h-16 bg-muted rounded-lg flex items-center justify-center overflow-hidden border">
{themeData.favicon_url ? (
<img
src={themeData.favicon_url}
alt="Favicon"
className="w-8 h-8 object-contain"
/>
) : (
<Globe className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/x-icon,image/vnd.microsoft.icon,image/png,image/svg+xml"
onChange={handleFaviconUpload}
className="hidden"
disabled={uploadingFavicon}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingFavicon}
asChild
>
<span>
<Upload className="h-4 w-4 mr-2" />
{uploadingFavicon ? 'Uploading...' : 'Upload'}
</span>
</Button>
</label>
{themeData.favicon_url && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDeleteFavicon}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
ICO, PNG, or SVG. Max 1MB.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Color Scheme Section */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Color Scheme
</CardTitle>
<CardDescription>
Customize the color palette used throughout the site. Colors are stored as HSL values.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{colorFields.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`color_${field.key}`}>{field.label}</Label>
<div className="flex items-center gap-2">
<input
type="color"
id={`color_${field.key}`}
value={hslToHex(themeData.colors[field.key])}
onChange={(e) => handleColorChange(field.key, hexToHsl(e.target.value))}
className="h-10 w-14 rounded border cursor-pointer"
/>
<Input
value={themeData.colors[field.key]}
onChange={(e) => handleColorChange(field.key, e.target.value)}
placeholder="280 47% 27%"
className="flex-1 font-mono text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">
{field.description}
</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Preview Section */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
See how your theme changes will look
</CardDescription>
</CardHeader>
<CardContent>
<div
className="rounded-lg border p-6 space-y-4"
style={{
'--preview-primary': themeData.colors.primary,
'--preview-accent': themeData.colors.accent,
'--preview-brand-purple': themeData.colors.brand_purple,
'--preview-brand-orange': themeData.colors.brand_orange,
'--preview-brand-lavender': themeData.colors.brand_lavender,
}}
>
<div className="flex items-center gap-4">
{themeData.logo_url ? (
<img src={themeData.logo_url} alt="Preview Logo" className="h-16 w-16 object-contain" />
) : (
<div className="h-16 w-16 bg-muted rounded-lg flex items-center justify-center">
<Image className="h-8 w-8 text-muted-foreground" />
</div>
)}
<div>
<h3 className="text-xl font-bold">{themeData.site_name || 'Site Name'}</h3>
<p className="text-sm text-muted-foreground">{themeData.site_short_name || 'Short Name'}</p>
{themeData.site_description && (
<p className="text-xs text-muted-foreground mt-1 max-w-md">{themeData.site_description}</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 pt-4">
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.primary})` }}
>
Primary
</div>
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.accent})` }}
>
Accent
</div>
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_purple})` }}
>
Brand Purple
</div>
<div
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_orange})` }}
>
Brand Orange
</div>
<div
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_lavender})` }}
>
Brand Lavender
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Reset Confirmation Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Reset Theme to Defaults?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all custom theme settings including uploaded logo and favicon.
The site will revert to the default LOAF theme. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleResetToDefaults}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Reset to Defaults
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default AdminTheme;

View File

@@ -10,6 +10,7 @@ import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera,
import { toast } from 'sonner'; import { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import ChangeRoleDialog from '../../components/ChangeRoleDialog'; import ChangeRoleDialog from '../../components/ChangeRoleDialog';
import StatusBadge from '../../components/StatusBadge';
import TransactionHistory from '../../components/TransactionHistory'; import TransactionHistory from '../../components/TransactionHistory';
const AdminUserView = () => { const AdminUserView = () => {
@@ -279,7 +280,7 @@ const AdminUserView = () => {
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
if (!user) return null; if (!user) return null;
const joinedDate = user.member_since || user.created_at; const joinedDate = user.created_at;
const memberSinceBaseline = formatDateInputValue(user.member_since); const memberSinceBaseline = formatDateInputValue(user.member_since);
const memberSinceHasChanges = memberSince !== memberSinceBaseline; const memberSinceHasChanges = memberSince !== memberSinceBaseline;
@@ -313,8 +314,9 @@ const AdminUserView = () => {
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</h1> </h1>
{/* Status & Role Badges */} {/* Status & Role Badges */}
<Badge>{user.status}</Badge> <StatusBadge status={user.status} />
<Badge>{user.role}</Badge> <StatusBadge status={user.role} />
</div> </div>
{/* Contact Info */} {/* Contact Info */}
@@ -333,7 +335,7 @@ const AdminUserView = () => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span>Joined {formatDateDisplayValue(joinedDate)}</span> <span>Registered: {formatDateDisplayValue(joinedDate)}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,8 +2,6 @@ import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { import {
Select, Select,
@@ -30,10 +28,14 @@ import {
PaginationEllipsis, PaginationEllipsis,
} from '../../components/ui/pagination'; } from '../../components/ui/pagination';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X } from 'lucide-react'; import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X, FileText, XCircle } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import RejectionDialog from '../../components/RejectionDialog'; import RejectionDialog from '../../components/RejectionDialog';
import StatusBadge from '@/components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { Button } from '@/components/ui/button';
import ViewRegistrationDialog from '@/components/ViewRegistrationDialog';
const AdminValidations = () => { const AdminValidations = () => {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -47,6 +49,8 @@ const AdminValidations = () => {
const [pendingAction, setPendingAction] = useState(null); const [pendingAction, setPendingAction] = useState(null);
const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false); const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false);
const [userToReject, setUserToReject] = useState(null); const [userToReject, setUserToReject] = useState(null);
const [viewRegistrationDialogOpen, setViewRegistrationDialogOpen] = useState(false);
const [selectedUserForView, setSelectedUserForView] = useState(null);
// Filtering state // Filtering state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -60,6 +64,9 @@ const AdminValidations = () => {
const [sortBy, setSortBy] = useState('created_at'); const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc'); const [sortOrder, setSortOrder] = useState('desc');
// Resend email state
const [resendLoading, setResendLoading] = useState(null);
useEffect(() => { useEffect(() => {
fetchPendingUsers(); fetchPendingUsers();
}, []); }, []);
@@ -235,23 +242,26 @@ const AdminValidations = () => {
} }
}; };
const getStatusBadge = (status) => { const handleRegistrationDialog = (user) => {
const config = { setSelectedUserForView(user);
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' }, setViewRegistrationDialogOpen(true);
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>
);
}; };
// Resend Email Handler
const handleResendVerification = async (user) => {
setResendLoading(user.id);
try {
await api.post(`/admin/users/${user.id}/resend-verification`);
toast.success(`Verification email sent to ${user.email}`);
fetchPendingUsers();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to send verification email');
} finally {
setResendLoading(null);
}
};
const handleSort = (column) => { const handleSort = (column) => {
if (sortBy === column) { if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
@@ -275,6 +285,37 @@ const AdminValidations = () => {
<ArrowDown className="h-4 w-4 inline ml-1" />; <ArrowDown className="h-4 w-4 inline ml-1" />;
}; };
const formatPhoneNumber = (phone) => {
if (!phone) return '-';
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)} - ${cleaned.slice(6)}`;
}
return phone;
};
const handleActionSelect = (user, action) => {
switch (action) {
case 'validate':
handleValidateRequest(user);
break;
case 'bypass_validate':
handleBypassAndValidateRequest(user);
break;
case 'resend_email':
handleResendVerification(user);
break;
case 'activate_payment':
handleActivatePayment(user);
break;
case 'reactivate':
handleReactivateUser(user);
break;
default:
break;
}
};
return ( return (
<> <>
{/* Header */} {/* Header */}
@@ -287,45 +328,54 @@ const AdminValidations = () => {
</p> </p>
</div> </div>
{/* Stats Card */} {/* Stats Card */}
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8"> <Card 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-2 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div> <StatCard
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Pending</p> title="Total Pending"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={loading ? '-' : pendingUsers.length}
{pendingUsers.length} icon={CheckCircle}
</p> iconBgClass="text-brand-purple"
</div> dataTestId="stat-total-users"
<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" }}> <StatCard
{pendingUsers.filter(u => u.status === 'pending_email').length} title="Awaiting Email"
</p> value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
</div> icon={CheckCircle}
<div> iconBgClass="text-brand-purple"
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p> dataTestId="stat-total-users"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> />
{pendingUsers.filter(u => u.status === 'pending_validation').length}
</p> <StatCard
</div> title="Pending Validation"
<div> value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p> icon={CheckCircle}
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> iconBgClass="text-brand-purple"
{pendingUsers.filter(u => u.status === 'pre_validated').length} dataTestId="stat-pending-validation"
</p> />
</div>
<div> <StatCard
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p> title="Payment Pending"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
{pendingUsers.filter(u => u.status === 'payment_pending').length} icon={CheckCircle}
</p> iconBgClass="text-brand-purple"
</div> dataTestId="stat-payment-pending"
<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" }}> <StatCard
{pendingUsers.filter(u => u.status === 'rejected').length} title="Rejected"
</p> value={loading ? '-' : pendingUsers.filter(u => u.status === 'rejected').length}
</div> icon={XCircle}
iconBgClass="text-red-600"
dataTestId="stat-rejected"
/>
</div> </div>
</Card> </Card>
@@ -346,13 +396,12 @@ const AdminValidations = () => {
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]"> <SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Filter by status" /> <SelectValue placeholder="Filter by status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="">
<SelectItem value="all">All Statuses</SelectItem> <SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Awaiting Email</SelectItem> <SelectItem value="pending_email" >Awaiting Email</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem> <SelectItem value="pending_validation" >Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem> <SelectItem value="payment_pending" >Payment Pending</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem> <SelectItem value="rejected" >Rejected</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -373,9 +422,8 @@ const AdminValidations = () => {
className="cursor-pointer hover:bg-[var(--neutral-800)]/20" className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
onClick={() => handleSort('first_name')} onClick={() => handleSort('first_name')}
> >
Name {renderSortIcon('first_name')} Member {renderSortIcon('first_name')}
</TableHead> </TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead> <TableHead>Phone</TableHead>
<TableHead <TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20" className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
@@ -389,6 +437,13 @@ const AdminValidations = () => {
> >
Registered {renderSortIcon('created_at')} Registered {renderSortIcon('created_at')}
</TableHead> </TableHead>
<TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
onClick={() => handleSort('email_verification_expires_at')}
>
{/* TODO: change ' ' */}
Validation Expiry {renderSortIcon('email_verification_expires_at')}
</TableHead>
<TableHead>Referred By</TableHead> <TableHead>Referred By</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
@@ -396,105 +451,100 @@ const AdminValidations = () => {
<TableBody> <TableBody>
{paginatedUsers.map((user) => ( {paginatedUsers.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell className="font-medium"> <TableCell className=" ">
{user.first_name} {user.last_name} <div className='font-semibold'>
{user.first_name} {user.last_name}
</div>
<div className='text-brand-purple'>
{user.email}
</div>
</TableCell> </TableCell>
<TableCell>{user.email}</TableCell> <TableCell>{formatPhoneNumber(user.phone)}</TableCell>
<TableCell>{user.phone}</TableCell> <TableCell><StatusBadge status={user.status} /></TableCell>
<TableCell>{getStatusBadge(user.status)}</TableCell>
<TableCell> <TableCell>
{new Date(user.created_at).toLocaleDateString()} {new Date(user.created_at).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell>
{user.email_verification_expires_at
? new Date(user.email_verification_expires_at).toLocaleString()
: '—'}
</TableCell>
<TableCell> <TableCell>
{user.referred_by_member_name || '-'} {user.referred_by_member_name || '-'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-2"> <div className='flex gap-2 justify-between'>
{user.status === 'rejected' ? (
<Select
value=""
onValueChange={(action) => handleActionSelect(user, action)}
disabled={actionLoading === user.id || resendLoading === user.id}
>
<SelectTrigger className="w-[180px] h-9 border-[var(--neutral-800)]">
<SelectValue placeholder={actionLoading === user.id || resendLoading === user.id ? 'Processing...' : 'Select Action'} />
</SelectTrigger>
<SelectContent>
{user.status === 'rejected' ? (
<SelectItem value="reactivate">Reactivate</SelectItem>
) : user.status === 'pending_email' ? (
<>
{hasPermission('users.approve') && (
<SelectItem value="bypass_validate">Bypass & Validate</SelectItem>
)}
{hasPermission('users.approve') && (
<SelectItem value="resend_email">Resend Email</SelectItem>
)}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
) : user.status === 'payment_pending' ? (
<>
{hasPermission('subscriptions.activate') && (
<SelectItem value="activate_payment">Activate Payment</SelectItem>
)}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
) : (
<>
{hasPermission('users.approve') && (
<SelectItem value="validate">Validate</SelectItem>
)}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
)}
</SelectContent>
</Select>
{/* view registration */}
<Button
onClick={() => handleRegistrationDialog(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-primary text-primary hover:bg-red-50 dark:hover:bg-red-500/10"
>
<FileText className="size-4" />
</Button>
{/* reject */}
{hasPermission('users.approve') && (
<Button <Button
onClick={() => handleReactivateUser(user)} onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
size="sm" size="sm"
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]" variant="outline"
className="border-2 mr-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
> >
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'} X
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -619,6 +669,13 @@ const AdminValidations = () => {
user={userToReject} user={userToReject}
loading={actionLoading !== null} loading={actionLoading !== null}
/> />
{/* View Registration Dialog */}
<ViewRegistrationDialog
open={viewRegistrationDialogOpen}
onOpenChange={setViewRegistrationDialogOpen}
user={selectedUserForView}
/>
</> </>
); );
}; };

View File

@@ -5,20 +5,34 @@ import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Scale, ExternalLink, History, Check } from 'lucide-react'; import { Scale, ExternalLink, History, Check, Search, Calendar } from 'lucide-react';
export default function Bylaws() { export default function Bylaws() {
const [currentBylaws, setCurrentBylaws] = useState(null); const [currentBylaws, setCurrentBylaws] = useState(null);
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const [years, setYears] = useState([]);
const [selectedYear, setSelectedYear] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetchCurrentBylaws(); fetchCurrentBylaws();
fetchYears();
fetchHistory(); fetchHistory();
}, []); }, []);
const fetchYears = async () => {
try {
const response = await api.get('/bylaws/years');
setYears(response.data);
} catch (error) {
console.error('Failed to load years');
}
};
const fetchCurrentBylaws = async () => { const fetchCurrentBylaws = async () => {
try { try {
const response = await api.get('/bylaws/current'); const response = await api.get('/bylaws/current');
@@ -32,15 +46,46 @@ export default function Bylaws() {
} }
}; };
const fetchHistory = async () => { const fetchHistory = async (year = null) => {
try { try {
const response = await api.get('/bylaws/history'); const url = year ? `/bylaws/history?year=${year}` : '/bylaws/history';
const response = await api.get(url);
setHistory(response.data); setHistory(response.data);
} catch (error) { } catch (error) {
console.error('Failed to load bylaws history'); console.error('Failed to load bylaws history');
} }
}; };
const handleYearFilter = (year) => {
setSelectedYear(year);
fetchHistory(year);
};
const clearFilter = () => {
setSelectedYear(null);
fetchHistory();
};
const filteredHistory = history.filter(bylaws =>
bylaws.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(bylaws.version && bylaws.version.toLowerCase().includes(searchTerm.toLowerCase()))
);
const groupByYear = (items) => {
const grouped = {};
items.forEach(item => {
const year = new Date(item.effective_date).getFullYear();
if (!grouped[year]) {
grouped[year] = [];
}
grouped[year].push(item);
});
return grouped;
};
const groupedHistory = groupByYear(filteredHistory.filter(b => !b.is_current));
const sortedYears = Object.keys(groupedHistory).sort((a, b) => b - a);
const formatDate = (dateString) => { const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@@ -75,6 +120,44 @@ export default function Bylaws() {
<p className="text-brand-purple 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. Review the official governing bylaws and policies of the LOAF community.
</p> </p>
{/* Filters */}
<div className="flex gap-4 flex-wrap items-center">
{/* Search */}
<div className="relative flex-1 min-w-[300px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-brand-purple " />
<Input
type="text"
placeholder="Search bylaws..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-[var(--neutral-800)] focus:border-brand-purple "
/>
</div>
{/* Year Filter */}
<div className="flex gap-2 flex-wrap">
<Button
onClick={clearFilter}
variant={selectedYear === null ? "default" : "outline"}
size="sm"
className={selectedYear === null ? "bg-brand-purple hover:bg-[var(--purple-muted)] text-white" : "border-brand-lavender text-brand-lavender "}
>
All Years
</Button>
{years.map(year => (
<Button
key={year}
onClick={() => handleYearFilter(year)}
variant={selectedYear === year ? "default" : "outline"}
size="sm"
className={selectedYear === year ? "bg-brand-purple text-white" : "border-brand-purple text-brand-purple "}
>
{year}
</Button>
))}
</div>
</div>
</div> </div>
{/* Current Bylaws */} {/* Current Bylaws */}
@@ -124,7 +207,7 @@ export default function Bylaws() {
)} )}
{/* Version History Toggle */} {/* Version History Toggle */}
{history.length > 1 && ( {filteredHistory.filter(b => !b.is_current).length > 0 && (
<div className="mb-6"> <div className="mb-6">
<Button <Button
onClick={() => setShowHistory(!showHistory)} onClick={() => setShowHistory(!showHistory)}
@@ -132,41 +215,48 @@ export default function Bylaws() {
className="w-full border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] 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" /> <History className="h-4 w-4" />
{showHistory ? 'Hide' : 'View'} Version History ({history.length - 1} previous {history.length - 1 === 1 ? 'version' : 'versions'}) {showHistory ? 'Hide' : 'View'} Version History ({filteredHistory.filter(b => !b.is_current).length} previous {filteredHistory.filter(b => !b.is_current).length === 1 ? 'version' : 'versions'})
</Button> </Button>
</div> </div>
)} )}
{/* Version History */} {/* Version History */}
{showHistory && history.length > 1 && ( {showHistory && filteredHistory.filter(b => !b.is_current).length > 0 && (
<div className="space-y-4"> <div className="space-y-6">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> {sortedYears.map(year => (
Previous Versions <div key={year}>
</h3> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{history.filter(b => !b.is_current).map(bylaws => ( <Calendar className="h-5 w-5" />
<Card key={bylaws.id} className="p-6 bg-[var(--lavender-600)] rounded-xl border border-[var(--neutral-800)]"> {year}
<div className="flex items-center justify-between"> </h3>
<div> <div className="space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> {groupedHistory[year].map(bylaws => (
{bylaws.title} <Card key={bylaws.id} className="p-6 bg-[var(--lavender-600)] rounded-xl border border-[var(--neutral-800)]">
</h4> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-sm text-brand-purple "> <div>
<span>Version {bylaws.version}</span> <h4 className="text-lg font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
<span></span> {bylaws.title}
<span>Effective {formatDate(bylaws.effective_date)}</span> </h4>
</div> <div className="flex items-center gap-3 text-sm text-brand-purple ">
</div> <span>Version {bylaws.version}</span>
<Button <span></span>
onClick={() => window.open(bylaws.document_url, '_blank')} <span>Effective {formatDate(bylaws.effective_date)}</span>
variant="outline" </div>
size="sm" </div>
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full flex items-center gap-2" <Button
> onClick={() => window.open(bylaws.document_url, '_blank')}
<ExternalLink className="h-4 w-4" /> variant="outline"
View size="sm"
</Button> 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
</Button>
</div>
</Card>
))}
</div> </div>
</Card> </div>
))} ))}
</div> </div>
)} )}

View File

@@ -285,7 +285,7 @@ const EventGallery = () => {
{/* Lightbox Modal */} {/* Lightbox Modal */}
<Dialog open={selectedImageIndex !== null} onOpenChange={closeLightbox}> <Dialog open={selectedImageIndex !== null} onOpenChange={closeLightbox}>
<DialogContent className="max-w-7xl w-full h-[90vh] p-0 bg-black border-0"> <DialogContent className="max-w-7xl w-full h-[90vh] p-0 bg-black border-0 overflow-y-auto max-h-[90vh]">
{selectedImageIndex !== null && galleryImages[selectedImageIndex] && ( {selectedImageIndex !== null && galleryImages[selectedImageIndex] && (
<div className="relative w-full h-full flex items-center justify-center"> <div className="relative w-full h-full flex items-center justify-center">
{/* Close Button */} {/* Close Button */}

View File

@@ -5,20 +5,36 @@ import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { DollarSign, ExternalLink, TrendingUp } from 'lucide-react'; import { DollarSign, ExternalLink, TrendingUp, Search, Calendar } from 'lucide-react';
export default function Financials() { export default function Financials() {
const [reports, setReports] = useState([]); const [reports, setReports] = useState([]);
const [years, setYears] = useState([]);
const [selectedYear, setSelectedYear] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetchYears();
fetchReports(); fetchReports();
}, []); }, []);
const fetchReports = async () => { const fetchYears = async () => {
try { try {
const response = await api.get('/financials'); const response = await api.get('/financials/years');
setYears(response.data);
} catch (error) {
console.error('Failed to load years');
}
};
const fetchReports = async (year = null) => {
try {
setLoading(true);
const url = year ? `/financials?year=${year}` : '/financials';
const response = await api.get(url);
setReports(response.data); setReports(response.data);
} catch (error) { } catch (error) {
toast.error('Failed to load financial reports'); toast.error('Failed to load financial reports');
@@ -27,6 +43,36 @@ export default function Financials() {
} }
}; };
const handleYearFilter = (year) => {
setSelectedYear(year);
fetchReports(year);
};
const clearFilter = () => {
setSelectedYear(null);
fetchReports();
};
const filteredReports = reports.filter(report =>
report.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
report.year.toString().includes(searchTerm)
);
const groupByYear = (items) => {
const grouped = {};
items.forEach(item => {
const year = item.year;
if (!grouped[year]) {
grouped[year] = [];
}
grouped[year].push(item);
});
return grouped;
};
const groupedReports = groupByYear(filteredReports);
const sortedYears = Object.keys(groupedReports).sort((a, b) => b - a);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -53,56 +99,100 @@ export default function Financials() {
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Access annual financial reports and stay informed about LOAF's fiscal responsibility. Access annual financial reports and stay informed about LOAF's fiscal responsibility.
</p> </p>
{/* Filters */}
<div className="flex gap-4 flex-wrap items-center">
{/* Search */}
<div className="relative flex-1 min-w-[300px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-brand-purple " />
<Input
type="text"
placeholder="Search financial reports..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-[var(--neutral-800)] focus:border-brand-purple "
/>
</div>
{/* Year Filter */}
<div className="flex gap-2 flex-wrap">
<Button
onClick={clearFilter}
variant={selectedYear === null ? "default" : "outline"}
size="sm"
className={selectedYear === null ? "bg-brand-purple hover:bg-[var(--purple-muted)] text-white" : "border-brand-lavender text-brand-lavender "}
>
All Years
</Button>
{years.map(year => (
<Button
key={year}
onClick={() => handleYearFilter(year)}
variant={selectedYear === year ? "default" : "outline"}
size="sm"
className={selectedYear === year ? "bg-brand-purple text-white" : "border-brand-purple text-brand-purple "}
>
{year}
</Button>
))}
</div>
</div>
</div> </div>
{/* Reports List */} {/* Reports List */}
{reports.length === 0 ? ( {filteredReports.length === 0 ? (
<Card className="p-12 text-center bg-background rounded-2xl border border-[var(--neutral-800)]"> <Card className="p-12 text-center bg-background rounded-2xl border border-[var(--neutral-800)]">
<TrendingUp className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" /> <TrendingUp 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" }}> <p className="text-brand-purple text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No financial reports available yet No financial reports found
</p> </p>
</Card> </Card>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-8">
{reports.map(report => ( {sortedYears.map(year => (
<Card key={report.id} className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow"> <div key={year}>
<div className="flex items-center gap-6"> <h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{/* Year Badge */} <Calendar className="h-6 w-6" />
<div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-6 rounded-xl text-white min-w-[120px] text-center"> {year}
<DollarSign className="h-8 w-8 mx-auto mb-2" /> </h2>
<div className="text-3xl font-bold" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="space-y-6">
{report.year} {groupedReports[year].map(report => (
</div> <Card key={report.id} className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow">
<div className="text-sm opacity-90">Fiscal Year</div> <div className="flex items-center gap-6">
</div> {/* Year Badge */}
<div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl self-start">
<DollarSign className="h-8 w-8 text-white" />
</div>
{/* Report Details */} {/* Report Details */}
<div className="flex-1"> <div className="flex-1">
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{report.title} {report.title}
</h3> </h3>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Badge variant="outline" className="border-brand-purple text-brand-purple "> <Badge variant="outline" className="border-brand-purple text-brand-purple ">
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()} {report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
</Badge> </Badge>
</div> </div>
<Button <Button
onClick={() => window.open(report.document_url, '_blank')} onClick={() => window.open(report.document_url, '_blank')}
className="bg-brand-purple text-white hover:bg-[var(--purple-muted)] 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-4 w-4" /> <ExternalLink className="h-4 w-4" />
View Report View Report
</Button> </Button>
</div> </div>
</div>
</Card>
))}
</div> </div>
</Card> </div>
))} ))}
</div> </div>
)} )}
{/* Transparency Note */} {/* Transparency Note */}
{reports.length > 0 && ( {filteredReports.length > 0 && (
<Card className="mt-8 p-6 bg-[var(--lavender-600)] border border-[var(--neutral-800)]"> <Card className="mt-8 p-6 bg-[var(--lavender-600)] border border-[var(--neutral-800)]">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<TrendingUp className="h-5 w-5 text-brand-purple mt-1" /> <TrendingUp className="h-5 w-5 text-brand-purple mt-1" />

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import api from '../../utils/api'; import api from '../../utils/api';
import Navbar from '../../components/Navbar'; import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter'; import MemberFooter from '../../components/MemberFooter';
@@ -15,74 +15,89 @@ import {
} from '../../components/ui/dialog'; } from '../../components/ui/dialog';
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react'; import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import MemberCard from '../../components/MemberCard';
import MemberBadge from '../../components/MemberBadge';
import useMembers from '../../hooks/use-members';
import useMemberTiers from '../../hooks/use-member-tiers';
const MembersDirectory = () => { const MembersDirectory = () => {
const [members, setMembers] = useState([]);
const [filteredMembers, setFilteredMembers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [selectedMember, setSelectedMember] = useState(null); const [selectedMember, setSelectedMember] = useState(null);
const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12; const pageSize = 12;
const { tiers } = useMemberTiers();
useEffect(() => { const allowedRoles = useMemo(() => [], []);
fetchMembers(); const normalizeStatus = useCallback((status) => {
if (typeof status === 'string') {
return status.toLowerCase();
}
return status;
}, []); }, []);
const normalizeMembers = useCallback(
(data) => {
const list = Array.isArray(data)
? data
: data?.members || data?.results || data?.items || data?.data || [];
useEffect(() => { return list.map((member) => ({
filterMembers(); ...member,
}, [searchQuery, members]); status: normalizeStatus(member.status ?? member.membership_status ?? member.member_status),
}));
},
[normalizeStatus]
);
const searchAccessor = useCallback(
(member) => [
`${member.first_name} ${member.last_name}`,
member.directory_bio || ''
],
[]
);
const handleFetchError = useCallback(() => {
toast({
title: "Error",
description: "Failed to load members directory. Please try again.",
variant: "destructive"
});
}, [toast]);
const {
users: members,
filteredUsers: filteredMembers,
loading,
searchQuery,
setSearchQuery,
filterValue,
} = useMembers({
endpoint: '/members/directory',
initialFilter: 'all',
filterKey: 'status',
allowedRoles,
searchAccessor,
transform: normalizeMembers,
fetchErrorMessage: 'Failed to load members directory. Please try again.',
onFetchError: handleFetchError
});
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [searchQuery, members]); }, [searchQuery, members]);
const fetchMembers = async () => {
try {
const response = await api.get('/members/directory');
setMembers(response.data);
setFilteredMembers(response.data);
} catch (error) {
console.error('Failed to fetch members:', error);
toast({
title: "Error",
description: "Failed to load members directory. Please try again.",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const filterMembers = () => {
if (!searchQuery.trim()) {
setFilteredMembers(members);
return;
}
const query = searchQuery.toLowerCase();
const filtered = members.filter(member => {
const fullName = `${member.first_name} ${member.last_name}`.toLowerCase();
const bio = (member.directory_bio || '').toLowerCase();
return fullName.includes(query) || bio.includes(query);
});
setFilteredMembers(filtered);
};
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize)); const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
const pageStart = (currentPage - 1) * pageSize; const pageStart = (currentPage - 1) * pageSize;
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize); const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
const totalMembers = members.length; const totalMembers = useMemo(() => {
if (!filterValue || filterValue === 'all') {
return members.length;
}
return members.filter((member) => member.status === filterValue).length;
}, [members, filterValue]);
const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
};
const getSocialMediaLink = (url) => { const getSocialMediaLink = (url) => {
if (!url) return null; if (!url) return null;
@@ -118,168 +133,6 @@ const MembersDirectory = () => {
: <div className=' border-2 w-full border-brand-purple mb-24' /> : <div className=' border-2 w-full border-brand-purple mb-24' />
) )
} }
const MemberCard = ({ member }) => {
const joinedDate = member.member_since || member.created_at;
return (
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
{/* Profile Photo */}
<div className="flex justify-center mb-4">
{member.profile_photo_url ? (
<img
src={member.profile_photo_url}
alt={`${member.first_name} ${member.last_name}`}
className="w-32 h-32 rounded-full object-cover border-4 border-[var(--neutral-800)]"
/>
) : (
<div className="w-32 h-32 rounded-full bg-[var(--neutral-800)] border-4 border-[var(--neutral-800)] flex items-center justify-center">
<span className="text-4xl font-semibold text-brand-purple " style={{ fontFamily: "'Inter', sans-serif" }}>
{getInitials(member.first_name, member.last_name)}
</span>
</div>
)}
</div>
{/* Name */}
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] text-center mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
{member.first_name} {member.last_name}
</h3>
{/* Partner Name */}
{member.directory_partner_name && (
<div className="flex items-center justify-center gap-2 mb-4">
<Heart className="h-4 w-4 text-[var(--orange-light)]" />
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Partner: {member.directory_partner_name}
</span>
</div>
)}
{/* Bio */}
{member.directory_bio && (
<p className="text-brand-purple text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{member.directory_bio}
</p>
)}
{/* Member Since */}
{joinedDate && (
<div className="flex items-center justify-center gap-2 mb-4">
<Calendar className="h-4 w-4 text-brand-purple " />
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Member since {new Date(joinedDate).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
})}
</span>
</div>
)}
{/* Contact Information */}
<div className="space-y-3 mb-4">
{member.directory_email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-brand-purple flex-shrink-0" />
<a
href={`mailto:${member.directory_email}`}
className="text-brand-purple hover:text-[var(--purple-ink)] truncate"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{member.directory_email}
</a>
</div>
)}
{member.directory_phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-brand-purple flex-shrink-0" />
<a
href={`tel:${member.directory_phone}`}
className="text-brand-purple hover:text-[var(--purple-ink)]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{member.directory_phone}
</a>
</div>
)}
{member.directory_address && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="h-4 w-4 text-brand-purple flex-shrink-0 mt-0.5" />
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{member.directory_address}
</span>
</div>
)}
</div>
{/* Social Media Links */}
{(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
<div className="pt-4 border-t border-[var(--neutral-800)]">
<div className="flex justify-center gap-3">
{member.social_media_facebook && (
<a
href={getSocialMediaLink(member.social_media_facebook)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
title="Facebook"
>
<Facebook className="h-5 w-5 text-[var(--blue-facebook)]" />
</a>
)}
{member.social_media_instagram && (
<a
href={getSocialMediaLink(member.social_media_instagram)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
title="Instagram"
>
<Instagram className="h-5 w-5 text-[var(--red-instagram)]" />
</a>
)}
{member.social_media_twitter && (
<a
href={getSocialMediaLink(member.social_media_twitter)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
title="Twitter/X"
>
<Twitter className="h-5 w-5 text-[var(--blue-twitter)]" />
</a>
)}
{member.social_media_linkedin && (
<a
href={getSocialMediaLink(member.social_media_linkedin)}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
title="LinkedIn"
>
<Linkedin className="h-5 w-5 text-[var(--blue-linkedin)]" />
</a>
)}
</div>
</div>
)}
{/* View Profile Button */}
<div className="pt-4 mt-4 border-t border-[var(--neutral-800)]">
<Button
onClick={() => handleViewProfile(member.id)}
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white rounded-full py-5"
>
<UserCircle className="h-4 w-4 mr-2" />
View Full Profile
</Button>
</div>
</Card>
);
};
return ( return (
<div className="min-h-screen bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)]"> <div className="min-h-screen bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)]">
@@ -296,7 +149,7 @@ const MembersDirectory = () => {
LOAF Members LOAF Members
</h1> </h1>
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span> <span className='text-foreground'>Number of current members in the directory: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span>
</p> </p>
</div> </div>
@@ -333,7 +186,7 @@ const MembersDirectory = () => {
) : filteredMembers.length > 0 ? ( ) : filteredMembers.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{paginatedMembers.map((member) => ( {paginatedMembers.map((member) => (
<MemberCard key={member.id} member={member} /> <MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} tiers={tiers} />
))} ))}
</div> </div>
) : ( ) : (
@@ -354,7 +207,7 @@ const MembersDirectory = () => {
{/* Border Decoration */} {/* Border Decoration */}
<Border yaxis="true" /> <Border yaxis="true" />
{/* todo: use badge to display if member */}
{/* Info Card */} {/* Info Card */}
{!loading && members.length > 0 && ( {!loading && members.length > 0 && (
<Card className="mt-12 p-6 bg-[var(--lavender-500)] border-[var(--neutral-800)]"> <Card className="mt-12 p-6 bg-[var(--lavender-500)] border-[var(--neutral-800)]">
@@ -377,15 +230,15 @@ const MembersDirectory = () => {
</Card> </Card>
)} )}
</div> </div>
{/* Profile Detail Dialog */} {/* Profile Detail Dialog */}
<Dialog open={profileDialogOpen} onOpenChange={setProfileDialogOpen}> <Dialog open={profileDialogOpen} onOpenChange={setProfileDialogOpen}>
<DialogContent className="sm:max-w-[600px] bg-background rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard"> <DialogContent className="sm:max-w-[600px] bg-background rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
{selectedMember && ( {selectedMember && (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedMember.first_name} {selectedMember.last_name} {selectedMember.first_name} {selectedMember.last_name}
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
</DialogTitle> </DialogTitle>
{selectedMember.directory_partner_name && ( {selectedMember.directory_partner_name && (
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -563,8 +416,6 @@ const MembersDirectory = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Pagination */} {/* Pagination */}
{!loading && filteredMembers.length > 0 && ( {!loading && filteredMembers.length > 0 && (
<div className="mt-10 flex flex-col items-center gap-4 pb-12"> <div className="mt-10 flex flex-col items-center gap-4 pb-12">

View File

@@ -167,8 +167,8 @@ export default function NewsletterArchive() {
{groupedNewsletters[year].map(newsletter => ( {groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow"> <Card key={newsletter.id} className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg flex-shrink-0"> <div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl">
<FileText className="h-6 w-6 text-brand-purple " /> <FileText className="h-8 w-8 text-white" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>

View File

@@ -54,3 +54,4 @@ h6 {
#ffffff 100% #ffffff 100%
); );
} }

View File

@@ -2,32 +2,6 @@
@layer base { @layer base {
:root { :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: var(--brand-orange);
--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;
/* ========================= /* =========================
Brand Colors Brand Colors
========================= */ ========================= */
@@ -47,7 +21,7 @@
/* /*
========================== ==========================
Color Patch Social Media Colors
========================== ==========================
*/ */
@@ -55,6 +29,50 @@
--blue-facebook: #1877f2; --blue-facebook: #1877f2;
--blue-twitter: #1da1f2; --blue-twitter: #1da1f2;
--red-instagram: #e4405f; --red-instagram: #e4405f;
/* =========================
Theme Colors
========================= */
--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: var(--brand-lavender);
--secondary-foreground: 280 47% 27%;
--muted: 268 43% 95%;
--muted-foreground: 268 35% 47%;
--accent: var(--brand-orange);
--accent-foreground: 280 47% 27%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--success: 147 23% 46%;
--success-foreground: 0 0% 98%;
--warning: var(--brand-orange);
--warning-foreground: 0 0% 10%;
--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;
--purple-ink: #422268; --purple-ink: #422268;
--purple-ink-2: #422268; --purple-ink-2: #422268;
--purple-deep: #48286e; --purple-deep: #48286e;

56
src/utils/member-tiers.js Normal file
View File

@@ -0,0 +1,56 @@
// src/utils/member-tiers.js
import { differenceInDays } from 'date-fns';
import { DEFAULT_MEMBER_TIERS } from '../config/MemberTiers';
/**
* Calculate tenure in years (with decimal precision)
* @param {string|Date} memberSince - The date the member joined
* @returns {number|null} - Years of membership or null if invalid
*/
export const getTenureYears = (memberSince) => {
if (!memberSince) return null;
const since = new Date(memberSince);
if (Number.isNaN(since.getTime())) return null;
const now = new Date();
const days = differenceInDays(now, since);
// Convert to years with decimal precision
return Math.max(0, days / 365.25);
};
/**
* Get the tier for a member based on their membership duration
* @param {string|Date} memberSince - The date the member joined
* @param {Array} tiers - Array of tier configurations
* @returns {Object} - The matching tier object
*/
export const getTierForMember = (memberSince, tiers = DEFAULT_MEMBER_TIERS) => {
const years = getTenureYears(memberSince);
if (years == null) return tiers[0];
const match = tiers.find(
(tier) =>
years >= tier.minYears &&
(tier.maxYears == null || years <= tier.maxYears)
);
return match || tiers[0];
};
/**
* Format tenure for display
* @param {string|Date} memberSince - The date the member joined
* @returns {string} - Human-readable tenure string
*/
export const formatTenure = (memberSince) => {
const years = getTenureYears(memberSince);
if (years == null) return 'Unknown';
if (years < 1) {
const months = Math.floor(years * 12);
return months <= 1 ? '< 1 month' : `${months} months`;
}
const wholeYears = Math.floor(years);
return wholeYears === 1 ? '1 year' : `${wholeYears} years`;
};

View File

@@ -49,6 +49,10 @@ module.exports = {
DEFAULT: 'hsl(var(--success))', DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))' foreground: 'hsl(var(--success-foreground))'
}, },
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))'
},
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',
@@ -101,6 +105,6 @@ module.exports = {
plugins: [ plugins: [
require("tailwindcss-animate"), require("tailwindcss-animate"),
require("@tailwindcss/typography") require("@tailwindcss/typography"),
], ],
}; };

View File

@@ -2552,6 +2552,11 @@
"@svgr/plugin-svgo" "^5.5.0" "@svgr/plugin-svgo" "^5.5.0"
loader-utils "^2.0.0" loader-utils "^2.0.0"
"@tailwindcss/line-clamp@^0.4.4":
version "0.4.4"
resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz#767cf8e5d528a5d90c9740ca66eb079f5e87d423"
integrity sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==
"@tailwindcss/typography@^0.5.19": "@tailwindcss/typography@^0.5.19":
version "0.5.19" version "0.5.19"
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.19.tgz#ecb734af2569681eb40932f09f60c2848b909456" resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.19.tgz#ecb734af2569681eb40932f09f60c2848b909456"