8 Commits

84 changed files with 2688 additions and 11495 deletions

View File

@@ -1,75 +0,0 @@
# 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/

View File

@@ -1,49 +0,0 @@
# 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,182 +999,3 @@ api.interceptors.response.use(
**Last Updated**: December 18, 2024
**Version**: 1.0.0
**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`

View File

@@ -1,44 +0,0 @@
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,7 +36,6 @@
"@radix-ui/react-tooltip": "^1.2.4",
"@stripe/react-stripe-js": "^2.0.0",
"@stripe/stripe-js": "^2.0.0",
"@tailwindcss/line-clamp": "^0.4.4",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -23,9 +23,7 @@ import AdminStaff from './pages/admin/AdminStaff';
import AdminMembers from './pages/admin/AdminMembers';
import AdminPermissions from './pages/admin/AdminPermissions';
import AdminSettings from './pages/admin/AdminSettings';
import AdminMemberTiers from './pages/admin/AdminMemberTiers';
import AdminRoles from './pages/admin/AdminRoles';
import AdminTheme from './pages/admin/AdminTheme';
import AdminEvents from './pages/admin/AdminEvents';
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
import AdminValidations from './pages/admin/AdminValidations';
@@ -33,7 +31,6 @@ import AdminPlans from './pages/admin/AdminPlans';
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
import AdminDonations from './pages/admin/AdminDonations';
import AdminLayout from './layouts/AdminLayout';
import SettingsLayout from './layouts/SettingsLayout';
import { AuthProvider, useAuth } from './context/AuthContext';
import MemberRoute from './components/MemberRoute';
import MemberCalendar from './pages/members/MemberCalendar';
@@ -47,8 +44,6 @@ import AdminGallery from './pages/admin/AdminGallery';
import AdminNewsletters from './pages/admin/AdminNewsletters';
import AdminFinancials from './pages/admin/AdminFinancials';
import AdminBylaws from './pages/admin/AdminBylaws';
import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder';
import AdminDirectorySettings from './pages/admin/AdminDirectorySettings';
import History from './pages/History';
import MissionValues from './pages/MissionValues';
import BoardOfDirectors from './pages/BoardOfDirectors';
@@ -240,20 +235,6 @@ function App() {
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/registration" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminRegistrationBuilder />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/member-tiers" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminMemberTiers />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/plans" element={
<PrivateRoute adminOnly>
<AdminLayout>
@@ -305,24 +286,18 @@ function App() {
} />
<Route path="/admin/permissions" element={
<PrivateRoute adminOnly>
<Navigate to="/admin/settings/permissions" replace />
<AdminLayout>
<AdminRoles />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/settings" element={
<PrivateRoute adminOnly>
<AdminLayout>
<SettingsLayout />
<AdminSettings />
</AdminLayout>
</PrivateRoute>
}>
<Route index element={<Navigate to="stripe" replace />} />
<Route path="stripe" element={<AdminSettings />} />
<Route path="permissions" element={<AdminRoles />} />
<Route path="theme" element={<AdminTheme />} />
<Route path="directory" element={<AdminDirectorySettings />} />
<Route path="registration" element={<AdminRegistrationBuilder />} />
</Route>
} />
{/* 404 - Catch all undefined routes */}
<Route path="*" element={<NotFound />} />

View File

@@ -1,222 +0,0 @@
import React, { useState } from 'react';
import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import { Label } from './ui/label';
import { CreditCard, AlertCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import api from '../utils/api';
/**
* AddPaymentMethodDialog - Dialog for adding a new payment method using Stripe Elements
*
* This dialog should be wrapped in an Elements provider with a clientSecret
*
* @param {string} saveEndpoint - Optional custom API endpoint for saving (default: '/payment-methods')
*/
const AddPaymentMethodDialog = ({
open,
onOpenChange,
onSuccess,
clientSecret,
saveEndpoint = '/payment-methods',
}) => {
const stripe = useStripe();
const elements = useElements();
const [loading, setLoading] = useState(false);
const [setAsDefault, setSetAsDefault] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
setLoading(true);
setError(null);
try {
// Get the CardElement
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
setError('Card element not found');
toast.error('Card element not found');
setLoading(false);
return;
}
// Confirm the SetupIntent with the card element
const { error: stripeError, setupIntent } = await stripe.confirmCardSetup(
clientSecret,
{
payment_method: {
card: cardElement,
},
}
);
if (stripeError) {
setError(stripeError.message);
toast.error(stripeError.message);
setLoading(false);
return;
}
if (setupIntent.status === 'succeeded') {
// Save the payment method to our backend using the specified endpoint
await api.post(saveEndpoint, {
stripe_payment_method_id: setupIntent.payment_method,
set_as_default: setAsDefault,
});
toast.success('Payment method added successfully');
onSuccess?.();
onOpenChange(false);
} else {
setError(`Setup failed with status: ${setupIntent.status}`);
toast.error('Failed to set up payment method');
}
} catch (err) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to save payment method';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
<DialogHeader className="bg-brand-purple text-white px-6 py-4">
<div className="flex items-center gap-3">
<CreditCard className="h-6 w-6" />
<div>
<DialogTitle
className="text-lg font-semibold text-white"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Add Payment Method
</DialogTitle>
<DialogDescription
className="text-white/80 text-sm"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Enter your card details securely
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Stripe Card Element */}
<div className="space-y-2">
<Label
className="text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Card Information
</Label>
<div className="border border-[var(--neutral-800)] rounded-xl p-4 bg-white">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: '#2d2a4a',
fontFamily: "'Nunito Sans', sans-serif",
'::placeholder': {
color: '#9ca3af',
},
},
invalid: {
color: '#ef4444',
},
},
hidePostalCode: false,
}}
/>
</div>
</div>
{/* Set as Default Checkbox */}
<div className="flex items-center space-x-3">
<Checkbox
id="setAsDefault"
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
className="border-brand-purple data-[state=checked]:bg-brand-purple"
/>
<Label
htmlFor="setAsDefault"
className="text-sm text-brand-purple cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Set as default payment method for future payments
</Label>
</div>
{/* Error Message */}
{error && (
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p
className="text-sm text-red-600"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{error}
</p>
</div>
)}
{/* Security Note */}
<p
className="text-xs text-brand-purple"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Your card information is securely processed by Stripe. We never store your full card number.
</p>
<DialogFooter className="flex-row gap-3 justify-end pt-4 border-t border-[var(--neutral-800)]">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={!stripe || loading}
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Add Card'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default AddPaymentMethodDialog;

View File

@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTheme } from 'next-themes';
import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import api from '../utils/api';
import { Badge } from './ui/badge';
import {
@@ -27,15 +26,12 @@ import {
Heart,
Sun,
Moon,
Star,
FileEdit
} from 'lucide-react';
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const { theme, setTheme } = useTheme();
const [pendingCount, setPendingCount] = useState(0);
const [storageUsed, setStorageUsed] = useState(0);
@@ -106,31 +102,18 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
path: '/admin',
disabled: false
},
{
name: 'Staff & Admins',
name: 'Staff',
icon: UserCog,
path: '/admin/staff',
disabled: false
},
{
name: 'Member Roster',
name: 'Members',
icon: Users,
path: '/admin/members',
disabled: false
},
{
name: 'Member Tiers',
icon: Star,
path: '/admin/member-tiers',
disabled: false
},
{
name: 'Registration',
icon: FileEdit,
path: '/admin/registration',
disabled: false
},
{
name: 'Validations',
icon: CheckCircle,
@@ -186,7 +169,13 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
path: '/admin/bylaws',
disabled: false
},
{
name: 'Permissions',
icon: Shield,
path: '/admin/permissions',
disabled: false,
superadminOnly: true
},
{
name: 'Settings',
icon: Settings,
@@ -298,7 +287,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]">
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
<img
src={getLogoUrl()}
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`}
@@ -331,18 +320,6 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* Dashboard - Standalone */}
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
{/* Onboarding Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Onboarding
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Registration'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
</div>
{/* MEMBERSHIP Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
@@ -352,9 +329,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Roster'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Tiers'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff & Admins'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Members'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
</div>
{/* FINANCIALS Section */}

View File

@@ -66,7 +66,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-background overflow-y-auto max-h-[90vh]">
<DialogContent className="sm:max-w-md bg-background">
<DialogHeader>
<div className="flex items-center gap-2 mb-2">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--lavender-300)]">
@@ -128,7 +128,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
<Button
type="button"
onClick={() => onOpenChange(false)}
className="btn-outline mr-33 text-white"
className="btn-outline mr-33"
>
Cancel
</Button>

View File

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

View File

@@ -181,7 +181,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
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"
placeholder="John"
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>

View File

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

View File

@@ -1,576 +0,0 @@
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

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
import logger from '../utils/logger';
import {
Dialog,
@@ -21,7 +20,6 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
* - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
* - Auto-logout on expiration
* - "Stay Logged In" extends session
* - Checks session validity when tab becomes visible after being hidden
*/
const IdleSessionWarning = () => {
const { user, logout, refreshUser } = useAuth();
@@ -35,13 +33,11 @@ const IdleSessionWarning = () => {
const [showWarning, setShowWarning] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(60); // seconds
const [isExtending, setIsExtending] = useState(false);
const [isCheckingSession, setIsCheckingSession] = useState(false);
const activityTimeoutRef = useRef(null);
const warningTimeoutRef = useRef(null);
const countdownIntervalRef = useRef(null);
const lastActivityRef = useRef(Date.now());
const lastVisibilityCheckRef = useRef(Date.now());
// Reset activity timer
const resetActivityTimer = useCallback(() => {
@@ -124,83 +120,6 @@ const IdleSessionWarning = () => {
}
};
// Check if session is still valid (called when tab becomes visible)
const checkSessionValidity = useCallback(async () => {
if (!user || isCheckingSession) return;
const token = localStorage.getItem('token');
if (!token) {
logger.log('[IdleSessionWarning] No token found on visibility change');
handleSessionExpired();
return;
}
setIsCheckingSession(true);
try {
// Make a lightweight API call to verify token is still valid
await api.get('/auth/me');
logger.log('[IdleSessionWarning] Session still valid after visibility change');
// Reset the activity timer since user is back
resetActivityTimer();
} catch (error) {
logger.error('[IdleSessionWarning] Session invalid on visibility change:', error);
// If 401, the interceptor will handle redirect
// For other errors, still redirect to be safe
if (error.response?.status !== 401) {
handleSessionExpired();
}
} finally {
setIsCheckingSession(false);
}
}, [user, isCheckingSession, handleSessionExpired, resetActivityTimer]);
// Handle visibility change (when user returns to tab after being away)
useEffect(() => {
if (!user) return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
const now = Date.now();
const timeSinceLastCheck = now - lastVisibilityCheckRef.current;
// Only check if tab was hidden for more than 1 minute
// This prevents unnecessary API calls for quick tab switches
if (timeSinceLastCheck > 60 * 1000) {
logger.log('[IdleSessionWarning] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds');
checkSessionValidity();
}
lastVisibilityCheckRef.current = now;
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [user, checkSessionValidity]);
// Listen for session expired event from API interceptor
useEffect(() => {
const handleAuthSessionExpired = () => {
logger.log('[IdleSessionWarning] Received auth:session-expired event');
// Clear all timers
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
setShowWarning(false);
};
window.addEventListener('auth:session-expired', handleAuthSessionExpired);
return () => {
window.removeEventListener('auth:session-expired', handleAuthSessionExpired);
};
}, []);
// Track user activity
useEffect(() => {
if (!user) return;

View File

@@ -1,281 +0,0 @@
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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogContent className="sm:max-w-[600px] rounded-2xl">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6" />
@@ -196,7 +196,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
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"
placeholder="John"
/>
</div>

View File

@@ -1,19 +0,0 @@
// 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

@@ -1,189 +0,0 @@
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';
import useDirectoryConfig from '../hooks/use-directory-config';
// 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;
const { isFieldEnabled } = useDirectoryConfig();
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 */}
{isFieldEnabled('directory_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 */}
{isFieldEnabled('directory_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">
{isFieldEnabled('directory_email') && 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>
)}
{isFieldEnabled('directory_phone') && 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>
)}
{isFieldEnabled('directory_address') && 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 */}
{isFieldEnabled('social_media') && (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/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
</div>
<p>© {new Date().getFullYear()} LOAF. All rights reserved.</p>
<p>© 2025 LOAF. All rights reserved.</p>
</div>
</div>
</div>

View File

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

View File

@@ -1,151 +0,0 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Shield, Eye, EyeOff, Loader2 } from 'lucide-react';
/**
* PasswordConfirmDialog - Dialog requiring admin password re-entry for sensitive actions
*/
const PasswordConfirmDialog = ({
open,
onOpenChange,
onConfirm,
title = 'Confirm Your Identity',
description = 'Please enter your password to proceed with this action.',
loading = false,
}) => {
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
if (!password.trim()) {
setError('Password is required');
return;
}
try {
await onConfirm(password);
setPassword('');
} catch (err) {
setError(err.message || 'Invalid password');
}
};
const handleOpenChange = (isOpen) => {
if (!isOpen) {
setPassword('');
setError(null);
}
onOpenChange(isOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
<DialogHeader className="bg-brand-purple text-white px-6 py-4">
<div className="flex items-center gap-3">
<Shield className="h-6 w-6" />
<div>
<DialogTitle
className="text-lg font-semibold text-white"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{title}
</DialogTitle>
<DialogDescription
className="text-white/80 text-sm"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{description}
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="space-y-2">
<Label
htmlFor="password"
className="text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Your Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
className="border-[var(--neutral-800)] pr-10"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-brand-purple hover:text-[var(--purple-ink)]"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{error && (
<p
className="text-sm text-red-500"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{error}
</p>
)}
<DialogFooter className="flex-row gap-3 justify-end pt-4 border-t border-[var(--neutral-800)]">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={loading}
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !password.trim()}
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Verifying...
</>
) : (
'Confirm'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default PasswordConfirmDialog;

View File

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

View File

@@ -1,186 +0,0 @@
import React from 'react';
import { CreditCard, Trash2, Star, Banknote, Building2, FileCheck } from 'lucide-react';
import { Button } from './ui/button';
/**
* Card brand icon mapping
*/
const getBrandIcon = (brand) => {
const brandLower = brand?.toLowerCase();
// Return text abbreviation for known brands
switch (brandLower) {
case 'visa':
return 'VISA';
case 'mastercard':
return 'MC';
case 'amex':
case 'american_express':
return 'AMEX';
case 'discover':
return 'DISC';
default:
return null;
}
};
/**
* Get icon for payment method type
*/
const getPaymentTypeIcon = (paymentType) => {
switch (paymentType) {
case 'cash':
return Banknote;
case 'bank_transfer':
return Building2;
case 'check':
return FileCheck;
default:
return CreditCard;
}
};
/**
* Format payment type for display
*/
const formatPaymentType = (paymentType) => {
switch (paymentType) {
case 'cash':
return 'Cash';
case 'bank_transfer':
return 'Bank Transfer';
case 'check':
return 'Check';
case 'card':
return 'Card';
default:
return paymentType;
}
};
/**
* PaymentMethodCard - Displays a single payment method
*/
const PaymentMethodCard = ({
method,
onSetDefault,
onDelete,
loading = false,
showActions = true,
}) => {
const PaymentIcon = getPaymentTypeIcon(method.payment_type);
const brandAbbr = method.card_brand ? getBrandIcon(method.card_brand) : null;
const isExpired = method.card_exp_year && method.card_exp_month &&
new Date(method.card_exp_year, method.card_exp_month) < new Date();
return (
<div
className={`flex items-center justify-between p-4 border rounded-xl ${
method.is_default
? 'border-brand-purple bg-[var(--lavender-500)]'
: 'border-[var(--neutral-800)] bg-white'
} ${isExpired ? 'opacity-70' : ''}`}
>
<div className="flex items-center gap-4">
{/* Payment Method Icon */}
<div className={`p-3 rounded-full ${
method.is_default
? 'bg-brand-purple text-white'
: 'bg-[var(--lavender-300)] text-brand-purple'
}`}>
<PaymentIcon className="h-5 w-5" />
</div>
{/* Payment Method Details */}
<div>
{method.payment_type === 'card' ? (
<>
<div className="flex items-center gap-2">
{brandAbbr && (
<span className="text-xs font-bold text-[var(--purple-ink)] bg-[var(--lavender-300)] px-2 py-0.5 rounded">
{brandAbbr}
</span>
)}
<span
className="font-medium text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{method.card_brand ? method.card_brand.charAt(0).toUpperCase() + method.card_brand.slice(1) : 'Card'} {method.card_last4 || '****'}
</span>
{method.is_default && (
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
<Star className="h-3 w-3 fill-current" />
Default
</span>
)}
</div>
<p
className={`text-sm ${isExpired ? 'text-red-500' : 'text-brand-purple'}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{isExpired ? 'Expired' : 'Expires'} {method.card_exp_month?.toString().padStart(2, '0')}/{method.card_exp_year?.toString().slice(-2)}
{method.card_funding && (
<span className="ml-2 text-xs capitalize">({method.card_funding})</span>
)}
</p>
</>
) : (
<>
<div className="flex items-center gap-2">
<span
className="font-medium text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{formatPaymentType(method.payment_type)}
</span>
{method.is_default && (
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
<Star className="h-3 w-3 fill-current" />
Default
</span>
)}
</div>
{method.manual_notes && (
<p
className="text-sm text-brand-purple"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{method.manual_notes}
</p>
)}
</>
)}
</div>
</div>
{/* Actions */}
{showActions && (
<div className="flex items-center gap-2">
{!method.is_default && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onSetDefault?.(method.id)}
disabled={loading}
className="border border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg text-xs px-3"
>
Set Default
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onDelete?.(method.id)}
disabled={loading}
className="border border-red-500 text-red-500 hover:bg-red-50 rounded-lg p-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
};
export default PaymentMethodCard;

View File

@@ -1,309 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import api from '../utils/api';
import useStripeConfig from '../hooks/use-stripe-config';
import PaymentMethodCard from './PaymentMethodCard';
import AddPaymentMethodDialog from './AddPaymentMethodDialog';
import ConfirmationDialog from './ConfirmationDialog';
/**
* PaymentMethodsSection - Manages user payment methods
*
* Features:
* - List saved payment methods
* - Add new payment method via Stripe SetupIntent
* - Set default payment method
* - Delete payment methods
*/
const PaymentMethodsSection = () => {
const [paymentMethods, setPaymentMethods] = useState([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(null);
// Get Stripe configuration from API
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
// Dialog states
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [clientSecret, setClientSecret] = useState(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [methodToDelete, setMethodToDelete] = useState(null);
/**
* Fetch payment methods from API
*/
const fetchPaymentMethods = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.get('/payment-methods');
setPaymentMethods(response.data);
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to load payment methods';
setError(errorMessage);
console.error('Failed to fetch payment methods:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPaymentMethods();
}, [fetchPaymentMethods]);
/**
* Create SetupIntent and open add dialog
*/
const handleAddNew = async () => {
try {
setActionLoading(true);
const response = await api.post('/payment-methods/setup-intent');
setClientSecret(response.data.client_secret);
setAddDialogOpen(true);
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to initialize payment setup';
toast.error(errorMessage);
console.error('Failed to create setup intent:', err);
} finally {
setActionLoading(false);
}
};
/**
* Handle successful payment method addition
*/
const handleAddSuccess = () => {
setAddDialogOpen(false);
setClientSecret(null);
fetchPaymentMethods();
};
/**
* Set a payment method as default
*/
const handleSetDefault = async (methodId) => {
try {
setActionLoading(true);
await api.put(`/payment-methods/${methodId}/default`);
toast.success('Default payment method updated');
fetchPaymentMethods();
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to update default payment method';
toast.error(errorMessage);
console.error('Failed to set default:', err);
} finally {
setActionLoading(false);
}
};
/**
* Open delete confirmation dialog
*/
const handleDeleteClick = (methodId) => {
setMethodToDelete(methodId);
setDeleteConfirmOpen(true);
};
/**
* Confirm and delete payment method
*/
const handleDeleteConfirm = async () => {
if (!methodToDelete) return;
try {
setActionLoading(true);
await api.delete(`/payment-methods/${methodToDelete}`);
toast.success('Payment method removed');
setDeleteConfirmOpen(false);
setMethodToDelete(null);
fetchPaymentMethods();
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to remove payment method';
toast.error(errorMessage);
console.error('Failed to delete payment method:', err);
} finally {
setActionLoading(false);
}
};
// Stripe Elements options - simplified for CardElement
const elementsOptions = {
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#6b5b95',
colorBackground: '#ffffff',
colorText: '#2d2a4a',
colorDanger: '#ef4444',
fontFamily: "'Nunito Sans', sans-serif",
borderRadius: '12px',
},
},
};
return (
<>
<Card className="space-y-4 px-6 pb-6">
{/* Header */}
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-0 flex items-center justify-between">
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
<h3
className="font-semibold"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Payment Methods
</h3>
</div>
<Button
type="button"
onClick={handleAddNew}
disabled={actionLoading}
size="sm"
className="bg-white text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg px-3 py-1"
>
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Add
</>
)}
</Button>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
<span
className="ml-2 text-brand-purple"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Loading payment methods...
</span>
</div>
)}
{/* Error State */}
{error && !loading && (
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
<p
className="text-sm text-red-600"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{error}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={fetchPaymentMethods}
className="ml-auto border-red-500 text-red-500 hover:bg-red-50 rounded-lg"
>
Retry
</Button>
</div>
)}
{/* Payment Methods List */}
{!loading && !error && (
<div className="space-y-3">
{paymentMethods.length === 0 ? (
<div className="text-center py-8">
<CreditCard className="h-12 w-12 text-[var(--lavender-500)] mx-auto mb-3" />
<p
className="text-brand-purple mb-2"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
No payment methods saved
</p>
<p
className="text-sm text-brand-purple/70 mb-4"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Add a card to make payments easier
</p>
<Button
type="button"
onClick={handleAddNew}
disabled={actionLoading}
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
>
{actionLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Setting up...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Add Payment Method
</>
)}
</Button>
</div>
) : (
paymentMethods.map((method) => (
<PaymentMethodCard
key={method.id}
method={method}
onSetDefault={handleSetDefault}
onDelete={handleDeleteClick}
loading={actionLoading}
/>
))
)}
</div>
)}
{/* Info Text */}
{!loading && paymentMethods.length > 0 && (
<p
className="text-xs text-brand-purple/70 pt-2 border-t border-[var(--neutral-800)]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Your default payment method will be used for subscription renewals and donations.
</p>
)}
</Card>
{/* Add Payment Method Dialog */}
{clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={elementsOptions}>
<AddPaymentMethodDialog
open={addDialogOpen}
onOpenChange={(open) => {
setAddDialogOpen(open);
if (!open) setClientSecret(null);
}}
onSuccess={handleAddSuccess}
clientSecret={clientSecret}
/>
</Elements>
)}
{/* Delete Confirmation Dialog */}
<ConfirmationDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
onConfirm={handleDeleteConfirm}
title="Remove Payment Method"
description="Are you sure you want to remove this payment method? This action cannot be undone."
confirmText="Remove"
cancelText="Cancel"
variant="danger"
loading={actionLoading}
/>
</>
);
};
export default PaymentMethodsSection;

View File

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

View File

@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Button } from './ui/button';
import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import { ChevronDown, Menu, X } from 'lucide-react';
import {
DropdownMenu,
@@ -13,7 +12,6 @@ import {
const PublicNavbar = () => {
const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate();
const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -32,8 +30,8 @@ const PublicNavbar = () => {
return location.pathname.startsWith('/about');
};
// Get logo URL from theme config (with fallback to default)
const loafLogo = getLogoUrl();
// LOAF logo (local)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
const handleAuthAction = () => {
if (user) {
@@ -77,24 +75,8 @@ const PublicNavbar = () => {
<div className='sticky top-0 inset-x-0 z-50'>
<header className="bg-gradient-to-r flex-wrap from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-[20px] py-[10px] flex md:justify-end justify-between items-center gap-4 sm:gap-6">
<div className='flex gap-4 sm:gap-6 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>
)}
<div className='flex gap-4 sm:gap-6'>
<button
onClick={handleAuthAction}
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
@@ -194,71 +176,7 @@ const PublicNavbar = () => {
Members Only
</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
<Link
to="/resources"
className={getDesktopLinkClasses('/resources')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
@@ -271,7 +189,7 @@ const PublicNavbar = () => {
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Contact Us
</Link> */}
</Link>
</nav>
</header>
@@ -301,18 +219,6 @@ const PublicNavbar = () => {
</button>
</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 */}
<nav className="flex flex-col p-6 space-y-4">
<Link
@@ -378,80 +284,6 @@ const PublicNavbar = () => {
</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
to="/resources"
onClick={() => setIsMobileMenuOpen(false)}
@@ -472,16 +304,6 @@ const PublicNavbar = () => {
{/* Auth Actions */}
<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
onClick={() => {
handleAuthAction();

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { CreditCard, Shield, Star, Palette, FileEdit, BookUser } from 'lucide-react';
const settingsItems = [
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
{ label: 'Directory', path: '/admin/settings/directory', icon: BookUser },
];
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,60 +7,30 @@ export const StatCard = ({
icon: Icon,
iconBgClass,
dataTestId,
}) => {
const valueString = value == null ? "" : String(value);
const digitCount =
valueString.replace(/\D/g, "").length || valueString.length;
const getValueFontSize = () => {
switch (true) {
case digitCount <= 2:
// 3.75rem for 3 or fewer digits
return "3.75rem";
case digitCount <= 6:
// Scale down for more digits
return "clamp(2rem, 5cqi, 3rem)";
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}
>
<div className="flex items-start gap-4 mb-4 justify-between">
<div
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>
}) => (
<Card
className="p-6 flex flex-col justify-between bg-background rounded-2xl border border-[var(--neutral-800)]"
data-testid={dataTestId}
>
<div className="flex items-start gap-4 mb-4 ">
<div className={`${iconBgClass} p-3 rounded-lg `}>
<Icon className="size-8" />
</div>
<p
className="text-sm text-brand-purple "
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{title}
</p>
</Card>
);
};
<div className="space-y-8">
<p
className="text-6xl font-semibold text-[var(--purple-ink)] mb-1"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{value}
</p>
</div>
</div>
<p
className="text-sm text-brand-purple "
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{title}
</p>
</Card>
);

View File

@@ -1,46 +0,0 @@
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' },
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' },
//donation badges
pending: { label: 'Payment Pending', variant: 'orange' },
completed: { label: 'Completed', variant: 'green' },
failed: { label: 'Failed', className: 'bg-red-100 text-red-700' }
};
//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={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
{isSubscription ? (
<CreditCard className="h-5 w-5 text-white" />
<CreditCard className="h-5 w-5 text-[var(--purple-lavender)]" />
) : (
<Heart className="h-5 w-5 text-white" />
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
)}
</div>
<div className="flex-1">

View File

@@ -1,539 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Checkbox } from './ui/checkbox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react';
import StatusBadge from './StatusBadge';
import api from '../utils/api';
import { toast } from 'sonner';
const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
if (!user) return null;
const [formData, setFormData] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const autoSaveTimeoutRef = useRef(null);
const pendingSaveRef = useRef(false);
const leadSourceOptions = [
'Current member',
'Friend',
'OutSmart Magazine',
'Search engine (Google etc.)',
"I've known about LOAF for a long time",
'Other'
];
const volunteerOptions = [
'Welcoming new members at events',
'Sending out birthday cards',
'Care Team Calls',
'Sharing ideas for events',
'Researching grants',
'Applying for grants',
'Assisting with TeatherLOAFers',
'Assisting with ActiveLOAFers',
'Assisting with weekday Lunch Bunch',
'Uploading Photos to the Website',
'Assisting with eNewsletter',
'Other administrative task'
];
useEffect(() => {
if (!open || !user) return;
const nextFormData = {
lead_sources: Array.isArray(user.lead_sources) ? user.lead_sources : [],
partner_first_name: user.partner_first_name || '',
partner_last_name: user.partner_last_name || '',
partner_is_member: Boolean(user.partner_is_member),
partner_plan_to_become_member: Boolean(user.partner_plan_to_become_member),
newsletter_publish_name: Boolean(user.newsletter_publish_name),
newsletter_publish_photo: Boolean(user.newsletter_publish_photo),
newsletter_publish_birthday: Boolean(user.newsletter_publish_birthday),
newsletter_publish_none: Boolean(user.newsletter_publish_none),
referred_by_member_name: user.referred_by_member_name || '',
volunteer_interests: Array.isArray(user.volunteer_interests) ? user.volunteer_interests : [],
scholarship_requested: Boolean(user.scholarship_requested),
scholarship_reason: user.scholarship_reason || ''
};
setFormData(nextFormData);
setHasUnsavedChanges(false);
}, [open, user]);
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, []);
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 saveProfile = async (showToast = true) => {
if (!formData) return;
if (isSaving) {
pendingSaveRef.current = true;
return;
}
setIsSaving(true);
try {
await api.put('/users/profile', {
lead_sources: formData.lead_sources,
partner_first_name: formData.partner_first_name,
partner_last_name: formData.partner_last_name,
partner_is_member: formData.partner_is_member,
partner_plan_to_become_member: formData.partner_plan_to_become_member,
newsletter_publish_name: formData.newsletter_publish_name,
newsletter_publish_photo: formData.newsletter_publish_photo,
newsletter_publish_birthday: formData.newsletter_publish_birthday,
newsletter_publish_none: formData.newsletter_publish_none,
referred_by_member_name: formData.referred_by_member_name,
volunteer_interests: formData.volunteer_interests,
scholarship_requested: formData.scholarship_requested,
scholarship_reason: formData.scholarship_reason
});
setHasUnsavedChanges(false);
if (showToast) {
toast.success('Registration details saved');
}
} catch (error) {
if (showToast) {
toast.error(error.response?.data?.detail || 'Failed to save registration details');
}
} finally {
setIsSaving(false);
if (pendingSaveRef.current) {
pendingSaveRef.current = false;
saveProfile(showToast);
}
}
};
const scheduleAutoSave = () => {
setHasUnsavedChanges(true);
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
saveProfile(false);
}, 800);
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => {
const next = { ...prev, [name]: value };
return next;
});
scheduleAutoSave();
};
const handleCheckboxChange = (name, checked) => {
setFormData(prev => ({ ...prev, [name]: checked }));
scheduleAutoSave();
};
const handleLeadSourceChange = (source) => {
setFormData(prev => {
const sources = prev.lead_sources.includes(source)
? prev.lead_sources.filter((item) => item !== source)
: [...prev.lead_sources, source];
return { ...prev, lead_sources: sources };
});
scheduleAutoSave();
};
const handleVolunteerChange = (option) => {
setFormData(prev => {
const interests = prev.volunteer_interests.includes(option)
? prev.volunteer_interests.filter((item) => item !== option)
: [...prev.volunteer_interests, option];
return { ...prev, volunteer_interests: interests };
});
scheduleAutoSave();
};
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={formData?.referred_by_member_name} />
<InfoRow icon={Clock} label="Email Verification Expires" value={formatDateTime(user.email_verification_expires_at)} />
</Card>
{formData && (
<>
{/* How Did You Hear About Us */}
<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" }}>
How Did You Hear About Us? *
</h3>
<div className="space-y-3">
{leadSourceOptions.map((source) => (
<div key={source} className="flex items-center space-x-2">
<Checkbox
id={`lead_${source}`}
checked={formData.lead_sources.includes(source)}
onCheckedChange={() => handleLeadSourceChange(source)}
/>
<Label htmlFor={`lead_${source}`} className="text-base cursor-pointer">
{source}
</Label>
</div>
))}
</div>
</Card>
{/* Partner 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" }}>
Partner Information (Optional)
</h3>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div>
<Label htmlFor="partner_first_name">Partner First Name</Label>
<Input
id="partner_first_name"
name="partner_first_name"
value={formData.partner_first_name}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
<div>
<Label htmlFor="partner_last_name">Partner Last Name</Label>
<Input
id="partner_last_name"
name="partner_last_name"
value={formData.partner_last_name}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="partner_is_member"
checked={formData.partner_is_member}
onCheckedChange={(checked) => handleCheckboxChange('partner_is_member', checked)}
/>
<Label htmlFor="partner_is_member" className="text-base cursor-pointer">
Is your partner already a member?
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="partner_plan_to_become_member"
checked={formData.partner_plan_to_become_member}
onCheckedChange={(checked) => handleCheckboxChange('partner_plan_to_become_member', checked)}
/>
<Label htmlFor="partner_plan_to_become_member" className="text-base cursor-pointer">
Does your partner plan to become a member?
</Label>
</div>
</div>
</Card>
{/* Newsletter Preferences */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Newsletter Publication Preferences *
</h3>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Please check what information may be published in LOAF Newsletter
</p>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_name"
checked={formData.newsletter_publish_name}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_name', checked)}
/>
<Label htmlFor="newsletter_publish_name" className="text-base cursor-pointer">
Name
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_photo"
checked={formData.newsletter_publish_photo}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_photo', checked)}
/>
<Label htmlFor="newsletter_publish_photo" className="text-base cursor-pointer">
Photo (added later in profile)
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_birthday"
checked={formData.newsletter_publish_birthday}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_birthday', checked)}
/>
<Label htmlFor="newsletter_publish_birthday" className="text-base cursor-pointer">
Birthday
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_none"
checked={formData.newsletter_publish_none}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_none', checked)}
/>
<Label htmlFor="newsletter_publish_none" className="text-base cursor-pointer">
Do not publish any of my information
</Label>
</div>
</div>
</Card>
{/* Referral */}
<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" }}>
Referral
</h3>
<div>
<Label htmlFor="referred_by_member_name">Name of a LOAF Member who already knows you</Label>
<Input
id="referred_by_member_name"
name="referred_by_member_name"
value={formData.referred_by_member_name}
onChange={handleInputChange}
placeholder="Enter member name or email"
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
If referred by a current member, you may skip the event attendance requirement.
</p>
</div>
</Card>
{/* Volunteer Interests */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests (Optional)
</h3>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
I may at some time be interested in volunteering with LOAF in the following ways (training is provided)
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{volunteerOptions.map((option) => (
<div key={option} className="flex items-center space-x-2">
<Checkbox
id={`volunteer_${option}`}
checked={formData.volunteer_interests.includes(option)}
onCheckedChange={() => handleVolunteerChange(option)}
/>
<Label htmlFor={`volunteer_${option}`} className="text-base cursor-pointer">
{option}
</Label>
</div>
))}
</div>
</Card>
{/* Scholarship Request */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center space-x-2">
<Checkbox
id="scholarship_requested"
checked={formData.scholarship_requested}
onCheckedChange={(checked) => handleCheckboxChange('scholarship_requested', checked)}
/>
<Label htmlFor="scholarship_requested" className="text-base cursor-pointer font-semibold">
I am requesting for scholarship
</Label>
</div>
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Scholarship information is kept confidential
</p>
{formData.scholarship_requested && (
<div className="mt-4">
<Label htmlFor="scholarship_reason">Please explain your situation *</Label>
<Textarea
id="scholarship_reason"
name="scholarship_reason"
value={formData.scholarship_reason}
onChange={handleInputChange}
placeholder="Tell us why you're requesting a scholarship..."
rows={4}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
)}
</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>
<div className="flex-1 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{isSaving && 'Saving changes...'}
{!isSaving && hasUnsavedChanges && 'Unsaved changes'}
{!isSaving && !hasUnsavedChanges && 'All changes saved'}
</div>
<Button
type="button"
onClick={() => saveProfile(true)}
disabled={!hasUnsavedChanges || isSaving}
className="rounded-xl border-2 border-[var(--neutral-800)] bg-white text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]"
>
Save All
</Button>
<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

@@ -1,531 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Textarea } from '../ui/textarea';
import { Label } from '../ui/label';
import {
CreditCard,
Plus,
Loader2,
AlertCircle,
Eye,
Banknote,
Building2,
FileCheck,
Trash2,
Star,
} from 'lucide-react';
import { toast } from 'sonner';
import api from '../../utils/api';
import useStripeConfig from '../../hooks/use-stripe-config';
import ConfirmationDialog from '../ConfirmationDialog';
import PasswordConfirmDialog from '../PasswordConfirmDialog';
import AddPaymentMethodDialog from '../AddPaymentMethodDialog';
/**
* Get icon for payment method type
*/
const getPaymentTypeIcon = (paymentType) => {
switch (paymentType) {
case 'cash':
return Banknote;
case 'bank_transfer':
return Building2;
case 'check':
return FileCheck;
default:
return CreditCard;
}
};
/**
* Format payment type for display
*/
const formatPaymentType = (paymentType) => {
switch (paymentType) {
case 'cash':
return 'Cash';
case 'bank_transfer':
return 'Bank Transfer';
case 'check':
return 'Check';
case 'card':
return 'Card';
default:
return paymentType;
}
};
/**
* AdminPaymentMethodsPanel - Admin panel for managing user payment methods
*/
const AdminPaymentMethodsPanel = ({ userId, userName }) => {
const [paymentMethods, setPaymentMethods] = useState([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(null);
// Get Stripe configuration from API
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
// Dialog states
const [addCardDialogOpen, setAddCardDialogOpen] = useState(false);
const [addManualDialogOpen, setAddManualDialogOpen] = useState(false);
const [clientSecret, setClientSecret] = useState(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [methodToDelete, setMethodToDelete] = useState(null);
const [revealDialogOpen, setRevealDialogOpen] = useState(false);
const [revealedData, setRevealedData] = useState(null);
// Manual payment form state
const [manualPaymentType, setManualPaymentType] = useState('cash');
const [manualNotes, setManualNotes] = useState('');
const [manualSetDefault, setManualSetDefault] = useState(false);
/**
* Fetch payment methods from API
*/
const fetchPaymentMethods = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.get(`/admin/users/${userId}/payment-methods`);
setPaymentMethods(response.data);
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to load payment methods';
setError(errorMessage);
console.error('Failed to fetch payment methods:', err);
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
if (userId) {
fetchPaymentMethods();
}
}, [userId, fetchPaymentMethods]);
/**
* Create SetupIntent for adding a card
*/
const handleAddCard = async () => {
try {
setActionLoading(true);
const response = await api.post(`/admin/users/${userId}/payment-methods/setup-intent`);
setClientSecret(response.data.client_secret);
setAddCardDialogOpen(true);
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to initialize payment setup';
toast.error(errorMessage);
console.error('Failed to create setup intent:', err);
} finally {
setActionLoading(false);
}
};
/**
* Handle successful card addition
*/
const handleCardAddSuccess = () => {
setAddCardDialogOpen(false);
setClientSecret(null);
fetchPaymentMethods();
};
/**
* Save manual payment method
*/
const handleSaveManualPayment = async () => {
try {
setActionLoading(true);
await api.post(`/admin/users/${userId}/payment-methods/manual`, {
payment_type: manualPaymentType,
manual_notes: manualNotes || null,
set_as_default: manualSetDefault,
});
toast.success('Manual payment method recorded');
setAddManualDialogOpen(false);
setManualPaymentType('cash');
setManualNotes('');
setManualSetDefault(false);
fetchPaymentMethods();
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to record payment method';
toast.error(errorMessage);
console.error('Failed to save manual payment:', err);
} finally {
setActionLoading(false);
}
};
/**
* Set a payment method as default
*/
const handleSetDefault = async (methodId) => {
try {
setActionLoading(true);
await api.put(`/admin/users/${userId}/payment-methods/${methodId}/default`);
toast.success('Default payment method updated');
fetchPaymentMethods();
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to update default';
toast.error(errorMessage);
} finally {
setActionLoading(false);
}
};
/**
* Confirm and delete payment method
*/
const handleDeleteConfirm = async () => {
if (!methodToDelete) return;
try {
setActionLoading(true);
await api.delete(`/admin/users/${userId}/payment-methods/${methodToDelete}`);
toast.success('Payment method removed');
setDeleteConfirmOpen(false);
setMethodToDelete(null);
fetchPaymentMethods();
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to remove payment method';
toast.error(errorMessage);
} finally {
setActionLoading(false);
}
};
/**
* Reveal sensitive payment details with password confirmation
*/
const handleRevealDetails = async (password) => {
try {
setActionLoading(true);
const response = await api.post(`/admin/users/${userId}/payment-methods/reveal`, {
password,
});
setRevealedData(response.data);
setRevealDialogOpen(false);
toast.success('Sensitive details revealed');
} catch (err) {
const errorMessage = err.response?.data?.detail || 'Failed to reveal details';
throw new Error(errorMessage);
} finally {
setActionLoading(false);
}
};
// Stripe Elements options - simplified for CardElement
const elementsOptions = {
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#6b5b95',
colorBackground: '#ffffff',
colorText: '#2d2a4a',
fontFamily: "'Nunito Sans', sans-serif",
borderRadius: '12px',
},
},
};
return (
<>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-brand-purple" />
<h2
className="text-lg font-semibold text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Payment Methods
</h2>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
onClick={() => setRevealDialogOpen(true)}
disabled={actionLoading || paymentMethods.length === 0}
variant="outline"
size="sm"
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg"
>
<Eye className="h-4 w-4 mr-1" />
Reveal Details
</Button>
<Button
type="button"
onClick={() => setAddManualDialogOpen(true)}
disabled={actionLoading}
variant="outline"
size="sm"
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg"
>
<Banknote className="h-4 w-4 mr-1" />
Add Manual
</Button>
<Button
type="button"
onClick={handleAddCard}
disabled={actionLoading}
size="sm"
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-lg"
>
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Add Card
</>
)}
</Button>
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
</div>
)}
{/* Error State */}
{error && !loading && (
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
<p className="text-sm text-red-600">{error}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={fetchPaymentMethods}
className="ml-auto"
>
Retry
</Button>
</div>
)}
{/* Payment Methods List */}
{!loading && !error && (
<div className="space-y-3">
{paymentMethods.length === 0 ? (
<p
className="text-center py-6 text-brand-purple"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
No payment methods on file for this user.
</p>
) : (
(revealedData || paymentMethods).map((method) => {
const PaymentIcon = getPaymentTypeIcon(method.payment_type);
return (
<div
key={method.id}
className={`flex items-center justify-between p-4 border rounded-xl ${
method.is_default
? 'border-brand-purple bg-[var(--lavender-500)]'
: 'border-[var(--neutral-800)] bg-white'
}`}
>
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-full ${
method.is_default
? 'bg-brand-purple text-white'
: 'bg-[var(--lavender-300)] text-brand-purple'
}`}
>
<PaymentIcon className="h-4 w-4" />
</div>
<div>
{method.payment_type === 'card' ? (
<>
<div className="flex items-center gap-2">
<span
className="font-medium text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{method.card_brand
? method.card_brand.charAt(0).toUpperCase() +
method.card_brand.slice(1)
: 'Card'}{' '}
{method.card_last4 || '****'}
</span>
{method.is_default && (
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
<Star className="h-3 w-3 fill-current" />
Default
</span>
)}
</div>
<p
className="text-sm text-brand-purple"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Expires {method.card_exp_month?.toString().padStart(2, '0')}/
{method.card_exp_year?.toString().slice(-2)}
{revealedData && method.stripe_payment_method_id && (
<span className="ml-2 text-xs font-mono bg-[var(--lavender-300)] px-2 py-0.5 rounded">
{method.stripe_payment_method_id}
</span>
)}
</p>
</>
) : (
<>
<div className="flex items-center gap-2">
<span
className="font-medium text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{formatPaymentType(method.payment_type)}
</span>
{method.is_default && (
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
<Star className="h-3 w-3 fill-current" />
Default
</span>
)}
</div>
{method.manual_notes && (
<p
className="text-sm text-brand-purple"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{method.manual_notes}
</p>
)}
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{!method.is_default && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleSetDefault(method.id)}
disabled={actionLoading}
className="text-xs"
>
Set Default
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setMethodToDelete(method.id);
setDeleteConfirmOpen(true);
}}
disabled={actionLoading}
className="border-red-500 text-red-500 hover:bg-red-50 p-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})
)}
</div>
)}
</Card>
{/* Add Card Dialog */}
{clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={elementsOptions}>
<AddPaymentMethodDialog
open={addCardDialogOpen}
onOpenChange={(open) => {
setAddCardDialogOpen(open);
if (!open) setClientSecret(null);
}}
onSuccess={handleCardAddSuccess}
clientSecret={clientSecret}
saveEndpoint={`/admin/users/${userId}/payment-methods`}
/>
</Elements>
)}
{/* Add Manual Payment Method Dialog */}
<ConfirmationDialog
open={addManualDialogOpen}
onOpenChange={setAddManualDialogOpen}
onConfirm={handleSaveManualPayment}
title="Record Manual Payment Method"
description={
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Payment Type</Label>
<Select value={manualPaymentType} onValueChange={setManualPaymentType}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cash">Cash</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Textarea
value={manualNotes}
onChange={(e) => setManualNotes(e.target.value)}
placeholder="e.g., Check #1234, received 01/15/2026"
className="min-h-[80px]"
/>
</div>
</div>
}
confirmText="Save"
variant="info"
loading={actionLoading}
/>
{/* Delete Confirmation Dialog */}
<ConfirmationDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
onConfirm={handleDeleteConfirm}
title="Remove Payment Method"
description="Are you sure you want to remove this payment method? This action cannot be undone."
confirmText="Remove"
variant="danger"
loading={actionLoading}
/>
{/* Password Confirm Dialog for Reveal */}
<PasswordConfirmDialog
open={revealDialogOpen}
onOpenChange={setRevealDialogOpen}
onConfirm={handleRevealDetails}
title="Reveal Sensitive Details"
description="Enter your password to view Stripe payment method IDs. This action will be logged."
loading={actionLoading}
/>
</>
);
};
export default AdminPaymentMethodsPanel;

View File

@@ -1,347 +0,0 @@
import React from 'react';
import { Button } from '../ui/button';
import StatusBadge from '../StatusBadge';
import {
ChevronDown,
ChevronUp,
Edit,
XCircle,
CreditCard,
Info,
ExternalLink,
Copy
} from 'lucide-react';
const HEADER_CELLS = [
{ label: 'Member', align: 'text-left' },
{ label: 'Plan', align: 'text-left' },
{ label: 'Status', align: 'text-left' },
{ label: 'Period', align: 'text-left' },
{ label: 'Base Fee', align: 'text-right' },
{ label: 'Donation', align: 'text-right' },
{ label: 'Total', align: 'text-right' },
{ label: 'Details', align: 'text-center' },
{ label: 'Actions', align: 'text-center' }
];
const HeaderCell = ({ align, children }) => (
<th
className={`p-4 text-[var(--purple-ink)] font-semibold ${align}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
{children}
</th>
);
const TableCell = ({ align = 'text-left', className = '', style, children, ...props }) => (
<td
className={`p-4 ${align} ${className}`.trim()}
style={style}
{...props}
>
{children}
</td>
);
const SubscriptionRow = ({
sub,
isExpanded,
onToggle,
onEdit,
onCancel,
hasPermission,
formatDate,
formatDateTime,
formatPrice,
copyToClipboard
}) => (
<>
<tr className="border-b border-[var(--neutral-800)] hover:bg-[var(--lavender-400)] transition-colors">
<TableCell>
<div className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.user.first_name} {sub.user.last_name}
</div>
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.user.email}
</div>
</TableCell>
<TableCell>
<div className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.plan.name}
</div>
<div className="text-xs text-brand-purple ">
{sub.plan.billing_cycle}
</div>
</TableCell>
<TableCell>
<StatusBadge status={sub.status} />
</TableCell>
<TableCell>
<div className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div>{formatDate(sub.start_date)}</div>
<div className="text-xs text-brand-purple ">to {formatDate(sub.end_date)}</div>
</div>
</TableCell>
<TableCell
align="text-right"
className="text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{formatPrice(sub.base_subscription_cents || 0)}
</TableCell>
<TableCell
align="text-right"
className="text-[var(--orange-light)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{formatPrice(sub.donation_cents || 0)}
</TableCell>
<TableCell
align="text-right"
className="font-semibold text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{formatPrice(sub.amount_paid_cents || 0)}
</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
onClick={onToggle}
className="text-brand-purple hover:bg-[var(--neutral-800)]"
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-2">
{hasPermission('subscriptions.edit') && (
<Button
size="sm"
variant="outline"
onClick={() => onEdit(sub)}
className="text-brand-purple hover:bg-[var(--neutral-800)]"
>
<Edit className="h-4 w-4" />
</Button>
)}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button
size="sm"
variant="outline-destructive"
onClick={() => onCancel(sub.id)}
>
<XCircle className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</tr>
{isExpanded && (
<tr className="bg-[var(--lavender-400)]/30">
<TableCell colSpan={9} className="p-6">
<div className="space-y-4">
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Transaction Details
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<CreditCard className="h-4 w-4" />
Payment Information
</h5>
<div className="space-y-2 text-sm">
{sub.payment_completed_at && (
<div className="flex justify-between">
<span className="text-brand-purple ">Payment Date:</span>
<span className="text-[var(--purple-ink)] font-medium">{formatDateTime(sub.payment_completed_at)}</span>
</div>
)}
{sub.payment_method && (
<div className="flex justify-between">
<span className="text-brand-purple ">Payment Method:</span>
<span className="text-[var(--purple-ink)] font-medium capitalize">{sub.payment_method}</span>
</div>
)}
{sub.card_brand && sub.card_last4 && (
<div className="flex justify-between">
<span className="text-brand-purple ">Card:</span>
<span className="text-[var(--purple-ink)] font-medium">{sub.card_brand} ****{sub.card_last4}</span>
</div>
)}
</div>
</div>
<div className="space-y-3">
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Info className="h-4 w-4" />
Stripe Transaction IDs
</h5>
<div className="space-y-2 text-sm">
{sub.stripe_payment_intent_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Payment Intent:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_payment_intent_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_payment_intent_id, 'Payment Intent ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_charge_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Charge ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_charge_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_charge_id, 'Charge ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_subscription_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Subscription ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_subscription_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_subscription_id, 'Subscription ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_invoice_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Invoice ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_invoice_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_invoice_id, 'Invoice ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_customer_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Customer ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_customer_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_customer_id, 'Customer ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_receipt_url && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Receipt:</span>
<Button
size="sm"
variant="outline"
onClick={() => window.open(sub.stripe_receipt_url, '_blank')}
className="text-brand-purple hover:bg-[var(--neutral-800)]"
>
<ExternalLink className="h-3 w-3 mr-1" />
View Receipt
</Button>
</div>
)}
</div>
</div>
</div>
</div>
</TableCell>
</tr>
)}
</>
);
const SubscriptionsTable = ({
subscriptions,
expandedRows,
onToggleRowExpansion,
onEdit,
onCancel,
hasPermission,
formatDate,
formatDateTime,
formatPrice,
copyToClipboard
}) => (
<table className="w-full">
<thead>
<tr className="bg-[var(--neutral-800)]/20 border-b border-[var(--neutral-800)]">
{HEADER_CELLS.map((cell) => (
<HeaderCell key={cell.label} align={cell.align}>
{cell.label}
</HeaderCell>
))}
</tr>
</thead>
<tbody>
{subscriptions.length > 0 ? (
subscriptions.map((sub) => (
<SubscriptionRow
key={sub.id}
sub={sub}
isExpanded={expandedRows.has(sub.id)}
onToggle={() => onToggleRowExpansion(sub.id)}
onEdit={onEdit}
onCancel={onCancel}
hasPermission={hasPermission}
formatDate={formatDate}
formatDateTime={formatDateTime}
formatPrice={formatPrice}
copyToClipboard={copyToClipboard}
/>
))
) : (
<tr>
<TableCell
align="text-center"
className="p-12 text-brand-purple "
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
colSpan={9}
>
No subscriptions found
</TableCell>
</tr>
)}
</tbody>
</table>
);
export default SubscriptionsTable;

View File

@@ -1,427 +0,0 @@
import React from 'react';
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Checkbox } from '../ui/checkbox';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
/**
* DynamicFormField - Renders form fields based on schema configuration
*
* Supports field types:
* - text, email, phone, password: Input fields
* - date: Date picker input
* - textarea: Multi-line text input
* - checkbox: Single checkbox
* - radio: Radio button group
* - dropdown: Select dropdown
* - multiselect: Checkbox group for multiple selections
* - address_group: Group of address-related fields
* - file_upload: File upload input
*/
const DynamicFormField = ({
field,
value,
onChange,
errors = [],
formData = {},
}) => {
const {
id,
type,
label,
required,
placeholder,
options = [],
rows = 4,
validation = {},
} = field;
const hasError = errors.length > 0;
const errorMessage = errors[0];
const formatPhoneNumber = (rawValue) => {
const digits = String(rawValue || '').replace(/\D/g, '').slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) {
return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
}
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
};
// Common input className
const inputClassName = `h-14 rounded-xl border-2 ${
hasError
? 'border-red-500 focus:border-red-500'
: 'border-[var(--neutral-800)] focus:border-brand-purple'
}`;
// Handle change for different field types
const handleInputChange = (e) => {
const { value: newValue, type: inputType, checked } = e.target;
if (inputType === 'checkbox') {
onChange(id, checked);
return;
}
if (type === 'phone') {
onChange(id, formatPhoneNumber(newValue));
return;
} else {
onChange(id, newValue);
}
};
const handleSelectChange = (newValue) => {
onChange(id, newValue);
};
const handleCheckboxChange = (checked) => {
onChange(id, checked);
};
const handleMultiselectChange = (optionValue) => {
const currentValues = Array.isArray(value) ? value : [];
const newValues = currentValues.includes(optionValue)
? currentValues.filter((v) => v !== optionValue)
: [...currentValues, optionValue];
onChange(id, newValues);
};
// Render error message
const renderError = () => {
if (!hasError) return null;
return (
<p className="text-sm text-red-500 mt-1">{errorMessage}</p>
);
};
// Render label
const renderLabel = () => (
<Label htmlFor={id} className={hasError ? 'text-red-500' : ''}>
{label} {required && '*'}
</Label>
);
// Render based on field type
switch (type) {
case 'text':
case 'email':
case 'phone':
return (
<div className="space-y-2">
{renderLabel()}
<Input
id={id}
name={id}
type={type === 'phone' ? 'tel' : type}
required={required}
value={value || ''}
onChange={handleInputChange}
placeholder={placeholder}
inputMode={type === 'phone' ? 'numeric' : undefined}
maxLength={type === 'phone' ? 14 : undefined}
className={inputClassName}
data-testid={`field-${id}`}
/>
{renderError()}
</div>
);
case 'password':
return (
<div className="space-y-2">
{renderLabel()}
<Input
id={id}
name={id}
type="password"
required={required}
value={value || ''}
onChange={handleInputChange}
placeholder={placeholder}
minLength={validation.minLength}
className={inputClassName}
data-testid={`field-${id}`}
/>
{renderError()}
</div>
);
case 'date':
return (
<div className="space-y-2">
{renderLabel()}
<Input
id={id}
name={id}
type="date"
required={required}
value={value || ''}
onChange={handleInputChange}
className={inputClassName}
data-testid={`field-${id}`}
/>
{renderError()}
</div>
);
case 'textarea':
return (
<div className="space-y-2">
{renderLabel()}
<Textarea
id={id}
name={id}
required={required}
value={value || ''}
onChange={handleInputChange}
placeholder={placeholder}
rows={rows}
className={`rounded-xl border-2 ${
hasError
? 'border-red-500 focus:border-red-500'
: 'border-[var(--neutral-800)] focus:border-brand-purple'
}`}
data-testid={`field-${id}`}
/>
{renderError()}
</div>
);
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<Checkbox
id={id}
name={id}
checked={value || false}
onCheckedChange={handleCheckboxChange}
data-testid={`field-${id}`}
/>
<Label
htmlFor={id}
className={`text-base cursor-pointer ${hasError ? 'text-red-500' : ''}`}
>
{label} {required && '*'}
</Label>
{renderError()}
</div>
);
case 'radio':
return (
<div className="space-y-2">
{renderLabel()}
<RadioGroup
value={value || ''}
onValueChange={handleSelectChange}
className="space-y-2"
>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem
value={option.value}
id={`${id}-${option.value}`}
data-testid={`field-${id}-${option.value}`}
/>
<Label
htmlFor={`${id}-${option.value}`}
className="text-base cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
{renderError()}
</div>
);
case 'dropdown':
return (
<div className="space-y-2">
{renderLabel()}
<Select value={value || ''} onValueChange={handleSelectChange}>
<SelectTrigger
className={`h-14 rounded-xl border-2 ${
hasError
? 'border-red-500'
: 'border-[var(--neutral-800)] focus:border-brand-purple'
}`}
data-testid={`field-${id}`}
>
<SelectValue placeholder={placeholder || 'Select an option'} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{renderError()}
</div>
);
case 'multiselect':
const selectedValues = Array.isArray(value) ? value : [];
return (
<div className="space-y-2">
{renderLabel()}
<div className="space-y-3">
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<Checkbox
id={`${id}-${option.value}`}
checked={selectedValues.includes(option.value)}
onCheckedChange={() => handleMultiselectChange(option.value)}
data-testid={`field-${id}-${option.value}`}
/>
<Label
htmlFor={`${id}-${option.value}`}
className="text-base cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</div>
{renderError()}
</div>
);
case 'address_group':
// Address group renders multiple related fields
return (
<div className="space-y-4">
{renderLabel()}
<div className="space-y-4">
<Input
id={`${id}_address`}
name={`${id}_address`}
placeholder="Street Address"
value={formData[`${id}_address`] || ''}
onChange={(e) => onChange(`${id}_address`, e.target.value)}
className={inputClassName}
required={required}
/>
<div className="grid md:grid-cols-3 gap-4">
<Input
id={`${id}_city`}
name={`${id}_city`}
placeholder="City"
value={formData[`${id}_city`] || ''}
onChange={(e) => onChange(`${id}_city`, e.target.value)}
className={inputClassName}
required={required}
/>
<Input
id={`${id}_state`}
name={`${id}_state`}
placeholder="State"
value={formData[`${id}_state`] || ''}
onChange={(e) => onChange(`${id}_state`, e.target.value)}
className={inputClassName}
required={required}
/>
<Input
id={`${id}_zipcode`}
name={`${id}_zipcode`}
placeholder="Zipcode"
value={formData[`${id}_zipcode`] || ''}
onChange={(e) => onChange(`${id}_zipcode`, e.target.value)}
className={inputClassName}
required={required}
/>
</div>
</div>
{renderError()}
</div>
);
case 'file_upload':
return (
<div className="space-y-2">
{renderLabel()}
<Input
id={id}
name={id}
type="file"
accept={field.allowed_types?.join(',')}
onChange={(e) => {
const file = e.target.files?.[0];
onChange(id, file);
}}
className={`h-14 rounded-xl border-2 pt-3 ${
hasError
? 'border-red-500'
: 'border-[var(--neutral-800)] focus:border-brand-purple'
}`}
data-testid={`field-${id}`}
/>
{field.max_size_mb && (
<p className="text-sm text-muted-foreground">
Max file size: {field.max_size_mb}MB
</p>
)}
{renderError()}
</div>
);
default:
console.warn(`Unknown field type: ${type}`);
return (
<div className="space-y-2">
{renderLabel()}
<Input
id={id}
name={id}
value={value || ''}
onChange={handleInputChange}
placeholder={placeholder}
className={inputClassName}
data-testid={`field-${id}`}
/>
{renderError()}
</div>
);
}
};
/**
* Get width class based on field width configuration
*/
export const getWidthClass = (width) => {
switch (width) {
case 'half':
return 'md:col-span-1';
case 'third':
return 'md:col-span-1';
case 'two-thirds':
return 'md:col-span-2';
case 'full':
default:
return 'md:col-span-2';
}
};
/**
* Get grid columns class based on field widths in a row
*/
export const getGridClass = (fields) => {
const hasThird = fields.some((f) => f.width === 'third');
if (hasThird) {
return 'grid md:grid-cols-3 gap-4';
}
return 'grid md:grid-cols-2 gap-4';
};
export default DynamicFormField;

View File

@@ -1,482 +0,0 @@
import React, { useMemo, useCallback } from 'react';
import DynamicFormField, { getWidthClass } from './DynamicFormField';
/**
* DynamicRegistrationForm - Renders the entire registration form from schema
*
* Features:
* - Renders steps and sections based on schema
* - Handles conditional field visibility
* - Supports step navigation
* - Validates fields per step
*/
const DynamicRegistrationForm = ({
schema,
formData,
onFormDataChange,
currentStep,
errors = {},
}) => {
// Get current step data
const stepData = useMemo(() => {
const steps = schema?.steps || [];
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
return sortedSteps[currentStep - 1] || null;
}, [schema, currentStep]);
// Evaluate conditional rules to determine which fields are visible
const hiddenFields = useMemo(() => {
const rules = schema?.conditional_rules || [];
const hidden = new Set();
// First pass: collect fields that have "show" rules (hidden by default)
for (const rule of rules) {
if (rule.action === 'show') {
rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
}
}
// Second pass: evaluate rules and show/hide fields
for (const rule of rules) {
const {
trigger_field,
trigger_operator = 'equals',
trigger_value,
action,
target_fields = [],
} = rule;
const fieldValue = formData[trigger_field];
let conditionMet = false;
switch (trigger_operator) {
case 'equals':
conditionMet = fieldValue === trigger_value;
break;
case 'not_equals':
conditionMet = fieldValue !== trigger_value;
break;
case 'contains':
conditionMet = Array.isArray(fieldValue)
? fieldValue.includes(trigger_value)
: String(fieldValue || '').includes(trigger_value);
break;
case 'not_empty':
conditionMet = Boolean(fieldValue);
break;
case 'empty':
conditionMet = !Boolean(fieldValue);
break;
default:
conditionMet = false;
}
if (conditionMet) {
if (action === 'show') {
target_fields.forEach((fieldId) => hidden.delete(fieldId));
} else if (action === 'hide') {
target_fields.forEach((fieldId) => hidden.add(fieldId));
}
}
}
return hidden;
}, [schema, formData]);
// Handle field change
const handleFieldChange = useCallback(
(fieldId, value) => {
onFormDataChange((prev) => ({
...prev,
[fieldId]: value,
}));
},
[onFormDataChange]
);
// Check if a field is visible
const isFieldVisible = useCallback(
(fieldId) => {
return !hiddenFields.has(fieldId);
},
[hiddenFields]
);
// Get errors for a specific field
const getFieldErrors = useCallback(
(fieldId) => {
return errors[fieldId] || [];
},
[errors]
);
// Group fields by their width for rendering
const groupFieldsByRow = (fields) => {
const rows = [];
let currentRow = [];
let currentRowWidth = 0;
const visibleFields = fields.filter((f) => isFieldVisible(f.id));
for (const field of visibleFields) {
const width = field.width || 'full';
let widthValue = 1;
if (width === 'half') widthValue = 0.5;
else if (width === 'third') widthValue = 0.33;
else if (width === 'two-thirds') widthValue = 0.67;
if (currentRowWidth + widthValue > 1) {
if (currentRow.length > 0) {
rows.push(currentRow);
}
currentRow = [field];
currentRowWidth = widthValue;
} else {
currentRow.push(field);
currentRowWidth += widthValue;
}
}
if (currentRow.length > 0) {
rows.push(currentRow);
}
return rows;
};
if (!stepData) {
return (
<div className="text-center py-8 text-muted-foreground">
No step data available
</div>
);
}
return (
<div className="space-y-8">
{/* Step Header */}
{stepData.description && (
<p
className="text-brand-purple"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{stepData.description}
</p>
)}
{/* Sections */}
{stepData.sections
?.sort((a, b) => a.order - b.order)
.map((section) => {
const visibleFields = section.fields?.filter((f) =>
isFieldVisible(f.id)
);
// Skip empty sections
if (!visibleFields || visibleFields.length === 0) {
return null;
}
const fieldRows = groupFieldsByRow(
section.fields?.sort((a, b) => a.order - b.order) || []
);
return (
<div key={section.id} className="space-y-4">
{/* Section Title */}
{section.title && (
<h2
className="text-2xl font-semibold text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{section.title}
</h2>
)}
{/* Section Description */}
{section.description && (
<p className="text-muted-foreground">{section.description}</p>
)}
{/* Fields */}
<div className="space-y-4">
{fieldRows.map((row, rowIndex) => {
// Determine grid class based on field widths
const hasThird = row.some((f) => f.width === 'third');
const hasHalf = row.some((f) => f.width === 'half');
const gridCols = hasThird
? 'grid md:grid-cols-3 gap-4'
: hasHalf
? 'grid md:grid-cols-2 gap-4'
: '';
if (row.length === 1 && !hasHalf && !hasThird) {
// Single full-width field
const field = row[0];
return (
<DynamicFormField
key={field.id}
field={field}
value={formData[field.id]}
onChange={handleFieldChange}
errors={getFieldErrors(field.id)}
formData={formData}
/>
);
}
return (
<div key={`row-${rowIndex}`} className={gridCols}>
{row.map((field) => (
<div
key={field.id}
className={getWidthClass(field.width)}
>
<DynamicFormField
field={field}
value={formData[field.id]}
onChange={handleFieldChange}
errors={getFieldErrors(field.id)}
formData={formData}
/>
</div>
))}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
};
/**
* DynamicStepIndicator - Renders step progress indicator
*/
export const DynamicStepIndicator = ({ steps, currentStep }) => {
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
return (
<div className="mb-8">
<div className="flex items-center justify-between">
{sortedSteps.map((step, index) => {
const stepNumber = index + 1;
const isActive = stepNumber === currentStep;
const isCompleted = stepNumber < currentStep;
return (
<div key={step.id} className="flex items-center flex-1">
{/* Step Circle */}
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg font-medium transition-colors ${
isActive
? 'bg-brand-purple text-white'
: isCompleted
? 'bg-green-500 text-white'
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]'
}`}
>
{isCompleted ? '✓' : stepNumber}
</div>
<span
className={`mt-2 text-sm text-center hidden md:block ${
isActive ? 'text-brand-purple font-medium' : 'text-muted-foreground'
}`}
>
{step.title}
</span>
</div>
{/* Connector Line */}
{index < sortedSteps.length - 1 && (
<div
className={`flex-1 h-1 mx-4 rounded ${
isCompleted
? 'bg-green-500'
: 'bg-[var(--neutral-800)]'
}`}
/>
)}
</div>
);
})}
</div>
</div>
);
};
/**
* Validate a single step based on schema
*/
export const validateStep = (stepData, formData, hiddenFields) => {
const errors = {};
if (!stepData?.sections) return { isValid: true, errors };
for (const section of stepData.sections) {
// Check section-level validation (e.g., atLeastOne)
const sectionValidation = section.validation || {};
if (sectionValidation.atLeastOne) {
const fieldIds = (section.fields || []).map((f) => f.id);
const hasValue = fieldIds.some((id) => {
if (hiddenFields.has(id)) return true; // Skip hidden fields
const value = formData[id];
return Boolean(value);
});
if (!hasValue) {
// Add error to first field in section
const firstFieldId = fieldIds[0];
if (firstFieldId) {
errors[firstFieldId] = [
sectionValidation.message ||
`At least one field in ${section.title || 'this section'} is required`,
];
}
}
}
// Check field-level validation
for (const field of section.fields || []) {
const { id, required, validation = {}, type, label } = field;
// Skip hidden fields
if (hiddenFields.has(id)) continue;
// Skip client-only fields for server validation
if (field.client_only && field.id !== 'confirmPassword') continue;
const value = formData[id];
// Required check
if (required) {
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
if (isEmpty) {
errors[id] = [`${label || id} is required`];
continue;
}
}
// Skip further validation if value is empty
if (!value && value !== false) continue;
// Type-specific validation
const fieldErrors = [];
if (type === 'email') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
fieldErrors.push('Please enter a valid email address');
}
}
if (type === 'password') {
if (validation.minLength && value.length < validation.minLength) {
fieldErrors.push(
`Password must be at least ${validation.minLength} characters`
);
}
}
if (type === 'text' || type === 'textarea') {
if (validation.minLength && value.length < validation.minLength) {
fieldErrors.push(
`${label || id} must be at least ${validation.minLength} characters`
);
}
if (validation.maxLength && value.length > validation.maxLength) {
fieldErrors.push(
`${label || id} must be at most ${validation.maxLength} characters`
);
}
}
// Match field validation (for confirmPassword)
if (validation.matchField) {
if (value !== formData[validation.matchField]) {
fieldErrors.push('Passwords do not match');
}
}
if (fieldErrors.length > 0) {
errors[id] = fieldErrors;
}
}
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
};
/**
* Evaluate conditional rules to get hidden fields set
*/
export const evaluateConditionalRules = (schema, formData) => {
const rules = schema?.conditional_rules || [];
const hidden = new Set();
// First pass: collect fields that have "show" rules (hidden by default)
for (const rule of rules) {
if (rule.action === 'show') {
rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
}
}
// Second pass: evaluate rules and show/hide fields
for (const rule of rules) {
const {
trigger_field,
trigger_operator = 'equals',
trigger_value,
action,
target_fields = [],
} = rule;
const fieldValue = formData[trigger_field];
let conditionMet = false;
switch (trigger_operator) {
case 'equals':
conditionMet = fieldValue === trigger_value;
break;
case 'not_equals':
conditionMet = fieldValue !== trigger_value;
break;
case 'contains':
conditionMet = Array.isArray(fieldValue)
? fieldValue.includes(trigger_value)
: String(fieldValue || '').includes(trigger_value);
break;
case 'not_empty':
conditionMet = Boolean(fieldValue);
break;
case 'empty':
conditionMet = !Boolean(fieldValue);
break;
default:
conditionMet = false;
}
if (conditionMet) {
if (action === 'show') {
target_fields.forEach((fieldId) => hidden.delete(fieldId));
} else if (action === 'hide') {
target_fields.forEach((fieldId) => hidden.add(fieldId));
}
}
}
return hidden;
};
export default DynamicRegistrationForm;

View File

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

View File

@@ -83,16 +83,16 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
<SelectPrimitive.Item
ref={ref}
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 hover:text-white focus:text-white",
"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",
className
)}
{...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>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText className="">{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName

View File

@@ -1,91 +1,78 @@
import * as React from "react";
import * as React from "react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full", className)} {...props} />
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
));
Table.displayName = "Table";
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn(
"bg-[var(--lavender-300)] border-b border-[var(--neutral-800)]",
className,
)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t border-[var(--neutral-800)] font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-[var(--neutral-800)] transition-colors hover:bg-[var(--lavender-400)]",
className,
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"p-4 text-left align-middle font-semibold text-[var(--purple-ink)] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] ",
className,
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
@@ -96,4 +83,4 @@ export {
TableRow,
TableCell,
TableCaption,
};
}

View File

@@ -1,70 +0,0 @@
// 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

@@ -1,29 +0,0 @@
// 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

@@ -1,161 +0,0 @@
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

@@ -1,93 +0,0 @@
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

@@ -1,92 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import api from '../utils/api';
/**
* Default directory configuration - used as fallback if API fails
*/
const DEFAULT_DIRECTORY_CONFIG = {
fields: {
show_in_directory: { enabled: true, label: 'Show in Directory', required: false },
directory_email: { enabled: true, label: 'Directory Email', required: false },
directory_bio: { enabled: true, label: 'Bio', required: false },
directory_address: { enabled: true, label: 'Address', required: false },
directory_phone: { enabled: true, label: 'Phone', required: false },
directory_dob: { enabled: true, label: 'Birthday', required: false },
directory_partner_name: { enabled: true, label: 'Partner Name', required: false },
volunteer_interests: { enabled: true, label: 'Volunteer Interests', required: false },
social_media: { enabled: true, label: 'Social Media Links', required: false },
}
};
/**
* Hook to fetch and manage directory field configuration
* @returns {Object} - { config, loading, error, isFieldEnabled, getFieldLabel, refetch }
*/
const useDirectoryConfig = () => {
const [config, setConfig] = useState(DEFAULT_DIRECTORY_CONFIG);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchConfig = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.get('/directory/config');
setConfig(response.data || DEFAULT_DIRECTORY_CONFIG);
} catch (err) {
console.error('Failed to fetch directory config:', err);
setError(err);
// Use default config on error
setConfig(DEFAULT_DIRECTORY_CONFIG);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
/**
* Check if a field is enabled in the config
* @param {string} fieldId - The field ID to check (e.g., 'directory_email')
* @returns {boolean} - Whether the field is enabled
*/
const isFieldEnabled = useCallback((fieldId) => {
const field = config?.fields?.[fieldId];
return field?.enabled !== false; // Default to true if not specified
}, [config]);
/**
* Get the label for a field
* @param {string} fieldId - The field ID
* @param {string} defaultLabel - Default label if not in config
* @returns {string} - The field label
*/
const getFieldLabel = useCallback((fieldId, defaultLabel = '') => {
const field = config?.fields?.[fieldId];
return field?.label || defaultLabel;
}, [config]);
/**
* Check if a field is required
* @param {string} fieldId - The field ID
* @returns {boolean} - Whether the field is required
*/
const isFieldRequired = useCallback((fieldId) => {
const field = config?.fields?.[fieldId];
return field?.required === true;
}, [config]);
return {
config,
loading,
error,
isFieldEnabled,
getFieldLabel,
isFieldRequired,
refetch: fetchConfig
};
};
export default useDirectoryConfig;

View File

@@ -1,106 +0,0 @@
// 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;

View File

@@ -1,90 +0,0 @@
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;

View File

@@ -1,91 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import api from '../utils/api';
// Cache the stripe promise to avoid multiple loads
let stripePromiseCache = null;
let cachedPublishableKey = null;
/**
* Hook to get Stripe configuration from the backend.
*
* Returns the Stripe publishable key and a pre-initialized Stripe promise.
* The publishable key is fetched from the backend API, allowing admins
* to configure it through the admin panel instead of environment variables.
*/
const useStripeConfig = () => {
const [publishableKey, setPublishableKey] = useState(cachedPublishableKey);
const [stripePromise, setStripePromise] = useState(stripePromiseCache);
const [loading, setLoading] = useState(!cachedPublishableKey);
const [error, setError] = useState(null);
const [environment, setEnvironment] = useState(null);
const fetchConfig = useCallback(async () => {
// If we already have a cached key, use it
if (cachedPublishableKey && stripePromiseCache) {
setPublishableKey(cachedPublishableKey);
setStripePromise(stripePromiseCache);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await api.get('/config/stripe');
const { publishable_key, environment: env } = response.data;
// Cache the key and stripe promise
cachedPublishableKey = publishable_key;
stripePromiseCache = loadStripe(publishable_key);
setPublishableKey(publishable_key);
setStripePromise(stripePromiseCache);
setEnvironment(env);
} catch (err) {
console.error('[useStripeConfig] Failed to fetch Stripe config:', err);
setError(err.response?.data?.detail || 'Failed to load Stripe configuration');
// Fallback to environment variable if available
const envKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY;
if (envKey) {
console.warn('[useStripeConfig] Falling back to environment variable');
cachedPublishableKey = envKey;
stripePromiseCache = loadStripe(envKey);
setPublishableKey(envKey);
setStripePromise(stripePromiseCache);
setEnvironment(envKey.startsWith('pk_live_') ? 'live' : 'test');
setError(null);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
// Function to clear cache (useful after admin updates settings)
const clearCache = useCallback(() => {
cachedPublishableKey = null;
stripePromiseCache = null;
setPublishableKey(null);
setStripePromise(null);
fetchConfig();
}, [fetchConfig]);
return {
publishableKey,
stripePromise,
loading,
error,
environment,
refetch: fetchConfig,
clearCache,
isConfigured: !!publishableKey,
};
};
export default useStripeConfig;

View File

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

View File

@@ -1,8 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useTheme } from 'next-themes';
import { Menu } from 'lucide-react';
import AdminSidebar from '../components/AdminSidebar';
import { UsersProvider } from '../context/UsersContext';
const AdminLayout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -48,48 +46,29 @@ const AdminLayout = ({ children }) => {
};
return (
<UsersProvider>
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
{/* Sidebar */}
<AdminSidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
isMobile={isMobile}
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
{/* Sidebar */}
<AdminSidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
isMobile={isMobile}
/>
{/* Mobile Overlay */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 transition-opacity"
onClick={closeSidebar}
/>
)}
{/* Mobile Overlay */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 transition-opacity"
onClick={closeSidebar}
/>
)}
{/* 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>
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto scrollbar-dashboard">
<div className="max-w-7xl mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
);
};

View File

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

View File

@@ -7,11 +7,8 @@ import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale } from 'lucide-react';
import { toast } from 'sonner';
import TransactionHistory from '../components/TransactionHistory';
import MemberBadge from '@/components/MemberBadge';
import useMemberTiers from '../hooks/use-member-tiers'
const Dashboard = () => {
const { user, resendVerificationEmail, refreshUser } = useAuth();
@@ -20,16 +17,11 @@ const Dashboard = () => {
const [resendLoading, setResendLoading] = useState(false);
const [eventActivity, setEventActivity] = useState(null);
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 { tiers, loading: tiersLoading } = useMemberTiers();
useEffect(() => {
fetchUpcomingEvents();
fetchEventActivity();
fetchTransactions();
}, []);
const fetchUpcomingEvents = async () => {
@@ -55,19 +47,6 @@ 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 () => {
setResendLoading(true);
try {
@@ -92,7 +71,6 @@ const Dashboard = () => {
}
};
const getStatusBadge = (status) => {
const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
@@ -133,8 +111,6 @@ const Dashboard = () => {
return messages[status] || '';
};
return (
<div className="min-h-screen bg-background">
<Navbar />
@@ -204,81 +180,58 @@ const Dashboard = () => {
</Card>
{/* Grid Layout */}
<div className="grid lg:grid-cols-2 gap-8">
<div className="grid lg:grid-cols-3 gap-8">
{/* Quick Stats */}
<div className='space-y-8'>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Quick Info
</h3>
<div className="space-y-4">
{/* member date and badge */}
<div className='flex justify-between'>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Quick Info
</h3>
<div className="space-y-4">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
</div>
<div>
<p className="text-sm text-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" }}>
{joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
</p>
</div>
{!tiersLoading && (
<div className='lg:mr-10'>
<MemberBadge memberSince={joinedDate} tiers={tiers} />
</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>
<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>
{/* Upcoming Events */}
<Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
<Card className="lg:col-span-2 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
My Event Activity
Upcoming Events
</h3>
<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
className="btn-lavender "
data-testid="view-all-events-button"
>
View All
</Button>
</Link>
</div>
@@ -360,18 +313,155 @@ const Dashboard = () => {
</Card>
)}
{/* 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>
{/* Event Activity Section */}
<div className="mt-12">
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
My Event Activity
</h2>
</div>
{activityLoading ? (
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event activity...</p>
) : eventActivity ? (
<div className="space-y-8">
{/* Stats Cards */}
<div className="grid md:grid-cols-2 gap-6">
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-4">
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-lg">
<Calendar className="h-8 w-8 text-brand-purple " />
</div>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{eventActivity.total_rsvps}
</p>
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-4">
<div className="bg-[var(--green-light)]/20 p-4 rounded-lg">
<CheckCircle className="h-8 w-8 text-[var(--green-light)]" />
</div>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Events Attended</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{eventActivity.total_attended}
</p>
</div>
</div>
</Card>
</div>
{/* Upcoming RSVP'd Events */}
{eventActivity.upcoming_events && eventActivity.upcoming_events.length > 0 && (
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Upcoming Events (RSVP'd)
</h3>
<div className="space-y-3">
{eventActivity.upcoming_events.map((event) => (
<Link to={`/events/${event.id}`} key={event.id}>
<div className="p-4 border border-[var(--neutral-800)] rounded-xl hover:border-brand-purple hover:shadow-md transition-all">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
</div>
<Badge className={
event.rsvp_status === 'yes' ? 'bg-[var(--green-light)] text-white' :
event.rsvp_status === 'maybe' ? 'bg-orange-100 text-orange-700' :
'bg-gray-200 text-gray-700'
}>
{event.rsvp_status === 'yes' ? 'Going' :
event.rsvp_status === 'maybe' ? 'Maybe' : 'Not Going'}
</Badge>
</div>
</div>
</Link>
))}
</div>
</Card>
)}
{/* Past Events & Attendance */}
{eventActivity.past_events && eventActivity.past_events.length > 0 && (
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Past Events
</h3>
<div className="space-y-3">
{eventActivity.past_events.slice(0, 5).map((event) => (
<div key={event.id} className="p-4 border border-[var(--neutral-800)] rounded-xl">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<Badge className={event.attended ? 'bg-[var(--green-light)] text-white' : 'bg-gray-200 text-gray-700'}>
{event.attended ? 'Attended' : 'Did not attend'}
</Badge>
{event.attended && event.attended_at && (
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Checked in: {new Date(event.attended_at).toLocaleDateString()}
</p>
)}
</div>
</div>
</div>
))}
</div>
{eventActivity.past_events.length > 5 && (
<p className="text-sm text-center text-brand-purple mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing 5 of {eventActivity.past_events.length} past events
</p>
)}
</Card>
)}
{/* No Events Message */}
{(!eventActivity.upcoming_events || eventActivity.upcoming_events.length === 0) &&
(!eventActivity.past_events || eventActivity.past_events.length === 0) && (
<Card className="p-12 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="text-center">
<Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Event Activity Yet
</h3>
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Browse upcoming events and RSVP to start building your event history!
</p>
<Link to="/events">
<Button className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender px-6">
<Calendar className="h-4 w-4 mr-2" />
Browse Events
</Button>
</Link>
</div>
</Card>
)}
</div>
) : (
<Card className="p-12 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="text-center">
<AlertCircle className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Failed to load event activity. Please try refreshing the page.
</p>
</div>
</Card>
)}
</div>
</div>
<MemberFooter />
</div>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, Link, useLocation, useSearchParams } from 'react-router-dom';
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
@@ -13,8 +13,6 @@ import { ArrowRight, ArrowLeft } from 'lucide-react';
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { login } = useAuth();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
@@ -22,30 +20,6 @@ const Login = () => {
password: ''
});
// Show session expiry message on mount
useEffect(() => {
const sessionParam = searchParams.get('session');
const stateMessage = location.state?.message;
if (sessionParam === 'expired') {
toast.info('Your session has expired. Please log in again.', {
duration: 5000,
});
// Clean up URL
window.history.replaceState({}, '', '/login');
} else if (sessionParam === 'idle') {
toast.info('You were logged out due to inactivity. Please log in again.', {
duration: 5000,
});
// Clean up URL
window.history.replaceState({}, '', '/login');
} else if (stateMessage) {
toast.info(stateMessage, {
duration: 5000,
});
}
}, [searchParams, location.state]);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Button } from '../components/ui/button';
@@ -6,221 +6,189 @@ import { Card } from '../components/ui/card';
import { toast } from 'sonner';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { ArrowRight, ArrowLeft, Loader2 } from 'lucide-react';
import DynamicRegistrationForm, {
DynamicStepIndicator,
validateStep,
evaluateConditionalRules,
} from '../components/registration/DynamicRegistrationForm';
import api from '../utils/api';
// Fallback schema for when API is unavailable
const FALLBACK_SCHEMA = {
version: '1.0',
steps: [
{
id: 'step_account',
title: 'Account Setup',
description: 'Create your account credentials.',
order: 1,
sections: [
{
id: 'section_credentials',
title: 'Account Credentials',
order: 1,
fields: [
{ id: 'first_name', type: 'text', label: 'First Name', required: true, is_fixed: true, mapping: 'first_name', width: 'half', order: 1 },
{ id: 'last_name', type: 'text', label: 'Last Name', required: true, is_fixed: true, mapping: 'last_name', width: 'half', order: 2 },
{ id: 'email', type: 'email', label: 'Email Address', required: true, is_fixed: true, mapping: 'email', width: 'full', order: 3 },
{ id: 'password', type: 'password', label: 'Password', required: true, is_fixed: true, mapping: 'password', validation: { minLength: 6 }, width: 'half', order: 4 },
{ id: 'confirmPassword', type: 'password', label: 'Confirm Password', required: true, is_fixed: true, client_only: true, width: 'half', order: 5, validation: { matchField: 'password' } },
{ id: 'accepts_tos', type: 'checkbox', label: 'I accept the Terms of Service and Privacy Policy', required: true, is_fixed: true, mapping: 'accepts_tos', width: 'full', order: 6 },
],
},
],
},
],
conditional_rules: [],
fixed_fields: ['email', 'password', 'first_name', 'last_name', 'accepts_tos'],
};
import { ArrowRight, ArrowLeft } from 'lucide-react';
import RegistrationStepIndicator from '../components/registration/RegistrationStepIndicator';
import RegistrationStep1 from '../components/registration/RegistrationStep1';
import RegistrationStep2 from '../components/registration/RegistrationStep2';
import RegistrationStep3 from '../components/registration/RegistrationStep3';
import RegistrationStep4 from '../components/registration/RegistrationStep4';
const Register = () => {
const navigate = useNavigate();
const { register } = useAuth();
const [loading, setLoading] = useState(false);
const [schemaLoading, setSchemaLoading] = useState(true);
const [schema, setSchema] = useState(null);
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
const [formData, setFormData] = useState({
// Step 1: Personal & Partner Information
first_name: '',
last_name: '',
phone: '',
date_of_birth: '',
address: '',
city: '',
state: '',
zipcode: '',
lead_sources: [],
partner_first_name: '',
partner_last_name: '',
partner_is_member: false,
partner_plan_to_become_member: false,
// Fetch registration schema on mount
useEffect(() => {
const fetchSchema = async () => {
try {
const response = await api.get('/registration/schema');
setSchema(response.data);
} catch (error) {
console.error('Failed to load registration schema:', error);
toast.error('Failed to load registration form. Using default form.');
setSchema(FALLBACK_SCHEMA);
} finally {
setSchemaLoading(false);
// Step 2: Newsletter, Volunteer & Scholarship
referred_by_member_name: '',
newsletter_publish_name: false,
newsletter_publish_photo: false,
newsletter_publish_birthday: false,
newsletter_publish_none: false,
volunteer_interests: [],
scholarship_requested: false,
scholarship_reason: '',
// Step 3: Directory Settings
show_in_directory: false,
directory_email: '',
directory_bio: '',
directory_address: '',
directory_phone: '',
directory_dob: '',
directory_partner_name: '',
// Step 4: Account Credentials
email: '',
password: '',
confirmPassword: ''
});
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const validateStep1 = () => {
const required = ['first_name', 'last_name', 'phone', 'date_of_birth',
'address', 'city', 'state', 'zipcode'];
for (const field of required) {
if (!formData[field]?.trim()) {
toast.error('Please fill in all required fields');
return false;
}
};
}
if (formData.lead_sources.length === 0) {
toast.error('Please select at least one option for how you heard about us');
return false;
}
return true;
};
fetchSchema();
}, []);
const validateStep2 = () => {
const { newsletter_publish_name, newsletter_publish_photo,
newsletter_publish_birthday, newsletter_publish_none } = formData;
// Get sorted steps
const sortedSteps = useMemo(() => {
if (!schema?.steps) return [];
return [...schema.steps].sort((a, b) => a.order - b.order);
}, [schema]);
// Get current step data
const currentStepData = useMemo(() => {
return sortedSteps[currentStep - 1] || null;
}, [sortedSteps, currentStep]);
// Get hidden fields based on conditional rules
const hiddenFields = useMemo(() => {
return evaluateConditionalRules(schema, formData);
}, [schema, formData]);
// Validate current step
const validateCurrentStep = useCallback(() => {
if (!currentStepData) return { isValid: true, errors: {} };
return validateStep(currentStepData, formData, hiddenFields);
}, [currentStepData, formData, hiddenFields]);
// Handle next step
const handleNext = () => {
const { isValid, errors: stepErrors } = validateCurrentStep();
if (!isValid) {
setErrors(stepErrors);
const firstErrorField = Object.keys(stepErrors)[0];
if (firstErrorField) {
toast.error(stepErrors[firstErrorField][0]);
}
return;
if (!newsletter_publish_name && !newsletter_publish_photo &&
!newsletter_publish_birthday && !newsletter_publish_none) {
toast.error('Please select at least one newsletter publication preference');
return false;
}
setErrors({});
setCurrentStep((prev) => Math.min(prev + 1, sortedSteps.length));
window.scrollTo({ top: 0, behavior: 'smooth' });
if (formData.scholarship_requested && !formData.scholarship_reason?.trim()) {
toast.error('Please explain your scholarship request');
return false;
}
return true;
};
const validateStep3 = () => {
return true; // No required fields
};
const validateStep4 = () => {
if (!formData.email || !formData.password || !formData.confirmPassword) {
toast.error('Please fill in all account fields');
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
toast.error('Please enter a valid email address');
return false;
}
if (formData.password.length < 6) {
toast.error('Password must be at least 6 characters');
return false;
}
if (formData.password !== formData.confirmPassword) {
toast.error('Passwords do not match');
return false;
}
return true;
};
const handleNext = () => {
let isValid = false;
switch (currentStep) {
case 1: isValid = validateStep1(); break;
case 2: isValid = validateStep2(); break;
case 3: isValid = validateStep3(); break;
default: isValid = false;
}
if (isValid) {
setCurrentStep(prev => Math.min(prev + 1, 4));
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// Handle previous step
const handleBack = () => {
setErrors({});
setCurrentStep((prev) => Math.max(prev - 1, 1));
setCurrentStep(prev => Math.max(prev - 1, 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
// Validate final step
const { isValid, errors: stepErrors } = validateCurrentStep();
if (!isValid) {
setErrors(stepErrors);
const firstErrorField = Object.keys(stepErrors)[0];
if (firstErrorField) {
toast.error(stepErrors[firstErrorField][0]);
}
return;
}
// Final validation
if (!validateStep4()) return;
setLoading(true);
try {
// Prepare submission data
const submitData = { ...formData };
// Remove client-only fields
delete submitData.confirmPassword;
// Remove confirmPassword (client-side only)
const { confirmPassword, ...dataToSubmit } = formData;
// Convert date fields to ISO format
if (submitData.date_of_birth) {
submitData.date_of_birth = new Date(submitData.date_of_birth).toISOString();
}
if (submitData.directory_dob) {
submitData.directory_dob = new Date(submitData.directory_dob).toISOString();
}
// Ensure boolean fields are actually booleans
const booleanFields = [
'partner_is_member',
'partner_plan_to_become_member',
'newsletter_publish_name',
'newsletter_publish_photo',
'newsletter_publish_birthday',
'newsletter_publish_none',
'scholarship_requested',
'show_in_directory',
'accepts_tos',
];
for (const field of booleanFields) {
if (field in submitData) {
submitData[field] = Boolean(submitData[field]);
}
}
// Ensure array fields are arrays
const arrayFields = ['lead_sources', 'volunteer_interests'];
for (const field of arrayFields) {
if (field in submitData && !Array.isArray(submitData[field])) {
submitData[field] = submitData[field] ? [submitData[field]] : [];
}
}
const submitData = {
...dataToSubmit,
date_of_birth: new Date(dataToSubmit.date_of_birth).toISOString(),
directory_dob: dataToSubmit.directory_dob
? new Date(dataToSubmit.directory_dob).toISOString()
: null
};
await register(submitData);
toast.success('Please check your email for a confirmation email.');
navigate('/login');
} catch (error) {
const errorMessage = error.response?.data?.detail;
if (typeof errorMessage === 'object' && errorMessage.errors) {
// Handle structured validation errors
const errorList = errorMessage.errors;
toast.error(errorList[0] || 'Registration failed');
} else {
toast.error(errorMessage || 'Registration failed. Please try again.');
}
toast.error(error.response?.data?.detail || 'Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
// Show loading state while fetching schema
if (schemaLoading) {
return (
<div className="min-h-screen bg-background">
<PublicNavbar />
<div className="max-w-4xl mx-auto px-6 py-12 flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-brand-purple" />
<p className="text-muted-foreground">Loading registration form...</p>
</div>
</div>
<PublicFooter />
</div>
);
}
return (
<div className="min-h-screen bg-background">
<PublicNavbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-8">
<Link
to="/"
className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors"
>
<Link to="/" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Home
</Link>
@@ -228,34 +196,47 @@ const Register = () => {
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
<div className="mb-8">
<h1
className="text-4xl md:text-5xl font-semibold text-[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" }}>
Join Our Community
</h1>
<p
className="text-lg text-brand-purple"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Fill out the form below to start your membership journey.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-8" data-testid="register-form">
{/* Step Indicator */}
{sortedSteps.length > 1 && (
<DynamicStepIndicator steps={sortedSteps} currentStep={currentStep} />
<RegistrationStepIndicator currentStep={currentStep} />
{currentStep === 1 && (
<RegistrationStep1
formData={formData}
setFormData={setFormData}
handleInputChange={handleInputChange}
/>
)}
{/* Dynamic Form Content */}
<DynamicRegistrationForm
schema={schema}
formData={formData}
onFormDataChange={setFormData}
currentStep={currentStep}
errors={errors}
/>
{currentStep === 2 && (
<RegistrationStep2
formData={formData}
setFormData={setFormData}
handleInputChange={handleInputChange}
/>
)}
{currentStep === 3 && (
<RegistrationStep3
formData={formData}
setFormData={setFormData}
handleInputChange={handleInputChange}
/>
)}
{currentStep === 4 && (
<RegistrationStep4
formData={formData}
handleInputChange={handleInputChange}
/>
)}
{/* Navigation Buttons */}
<div className="flex justify-between items-center pt-6">
@@ -264,7 +245,7 @@ const Register = () => {
type="button"
onClick={handleBack}
variant="outline"
className="rounded-full px-6 py-6 text-lg border-2 border-[var(--neutral-800)] hover:border-brand-purple text-[var(--purple-ink)]"
className="rounded-full px-6 py-6 text-lg border-2 border-[var(--neutral-800)] hover:border-brand-purple text-[var(--purple-ink)]"
>
<ArrowLeft className="mr-2 h-5 w-5" />
Back
@@ -273,7 +254,7 @@ const Register = () => {
<div></div>
)}
{currentStep < sortedSteps.length ? (
{currentStep < 4 ? (
<Button
type="button"
onClick={handleNext}
@@ -286,28 +267,16 @@ const Register = () => {
<Button
type="submit"
disabled={loading}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-backgroundrounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="submit-register-button"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Creating Account...
</>
) : (
<>
Create Account
<ArrowRight className="ml-2 h-5 w-5" />
</>
)}
{loading ? 'Creating Account...' : 'Create Account'}
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
)}
</div>
<p
className="text-center text-brand-purple mt-4"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
<p className="text-center text-brand-purple mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Already have an account?{' '}
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
Login here

View File

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

View File

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

View File

@@ -1,241 +0,0 @@
import React, { useState, useEffect } from 'react';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Switch } from '../../components/ui/switch';
import { Label } from '../../components/ui/label';
import { toast } from 'sonner';
import { BookUser, Save, RotateCcw, Loader2, AlertCircle } from 'lucide-react';
import {
Alert,
AlertDescription,
} from '../../components/ui/alert';
const AdminDirectorySettings = () => {
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [initialConfig, setInitialConfig] = useState(null);
useEffect(() => {
fetchConfig();
}, []);
useEffect(() => {
if (initialConfig && config) {
const changed = JSON.stringify(config) !== JSON.stringify(initialConfig);
setHasChanges(changed);
}
}, [config, initialConfig]);
const fetchConfig = async () => {
try {
setLoading(true);
const response = await api.get('/admin/directory/config');
setConfig(response.data.config);
setInitialConfig(response.data.config);
} catch (error) {
console.error('Failed to fetch directory config:', error);
toast.error('Failed to load directory configuration');
} finally {
setLoading(false);
}
};
const handleToggleField = (fieldId) => {
// Don't allow disabling show_in_directory - it's required
if (fieldId === 'show_in_directory') {
toast.error('The "Show in Directory" field cannot be disabled');
return;
}
setConfig(prev => ({
...prev,
fields: {
...prev.fields,
[fieldId]: {
...prev.fields[fieldId],
enabled: !prev.fields[fieldId].enabled
}
}
}));
};
const handleSave = async () => {
try {
setSaving(true);
await api.put('/admin/directory/config', { config });
setInitialConfig(config);
setHasChanges(false);
toast.success('Directory configuration saved successfully');
} catch (error) {
console.error('Failed to save directory config:', error);
toast.error('Failed to save directory configuration');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!window.confirm('Are you sure you want to reset to default settings? This will enable all directory fields.')) {
return;
}
try {
setSaving(true);
const response = await api.post('/admin/directory/config/reset');
setConfig(response.data.config);
setInitialConfig(response.data.config);
setHasChanges(false);
toast.success('Directory configuration reset to defaults');
} catch (error) {
console.error('Failed to reset directory config:', error);
toast.error('Failed to reset directory configuration');
} finally {
setSaving(false);
}
};
const handleCancel = () => {
setConfig(initialConfig);
setHasChanges(false);
};
// Field descriptions for better UX
const fieldDescriptions = {
show_in_directory: 'Master toggle for members to opt-in to the directory (always enabled)',
directory_email: 'Email address visible to other members in the directory',
directory_bio: 'Short biography shown in directory profile and member cards',
directory_address: 'Physical address visible to other members',
directory_phone: 'Phone number visible to other members',
directory_dob: 'Birthday shown in directory profiles',
directory_partner_name: 'Partner name displayed in directory',
volunteer_interests: 'Volunteer interest badges shown in profiles',
social_media: 'Social media links (Facebook, Instagram, Twitter, LinkedIn)',
};
// Field icons for better UX
const fieldLabels = {
show_in_directory: 'Show in Directory Toggle',
directory_email: 'Directory Email',
directory_bio: 'Bio / About',
directory_address: 'Address',
directory_phone: 'Phone Number',
directory_dob: 'Birthday',
directory_partner_name: 'Partner Name',
volunteer_interests: 'Volunteer Interests',
social_media: 'Social Media Links',
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-brand-purple" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<BookUser className="h-6 w-6 text-brand-purple" />
Directory Field Settings
</h2>
<p className="text-muted-foreground mt-1">
Configure which fields are available in member profiles and the directory
</p>
</div>
</div>
{/* Info Alert */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
These settings control which fields appear in the <strong>Profile page</strong> and <strong>Member Directory</strong>.
Disabling a field will hide it from both locations. Existing data will be preserved but not displayed.
</AlertDescription>
</Alert>
{/* Fields Configuration */}
<Card className="p-6">
<div className="space-y-6">
{config && Object.entries(config.fields).map(([fieldId, fieldData]) => (
<div
key={fieldId}
className={`flex items-center justify-between p-4 rounded-lg border ${
fieldData.enabled ? 'bg-background border-border' : 'bg-muted/50 border-muted'
}`}
>
<div className="flex-1">
<Label className="text-base font-medium text-foreground">
{fieldLabels[fieldId] || fieldData.label || fieldId}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{fieldDescriptions[fieldId] || fieldData.description}
</p>
</div>
<Switch
checked={fieldData.enabled}
onCheckedChange={() => handleToggleField(fieldId)}
disabled={fieldId === 'show_in_directory'}
className="data-[state=checked]:bg-brand-purple"
/>
</div>
))}
</div>
</Card>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t">
<Button
variant="outline"
onClick={handleReset}
disabled={saving}
className="text-muted-foreground"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</Button>
<div className="flex items-center gap-3">
{hasChanges && (
<span className="text-sm text-muted-foreground flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-orange-500"></span>
Unsaved changes
</span>
)}
<Button
variant="outline"
onClick={handleCancel}
disabled={!hasChanges || saving}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saving}
className="bg-brand-purple hover:bg-brand-purple/90 text-white"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</div>
</div>
);
};
export default AdminDirectorySettings;

View File

@@ -3,7 +3,6 @@ import { useAuth } from '../../context/AuthContext';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import StatusBadge from '@/components/StatusBadge';
import {
Select,
SelectContent,
@@ -18,14 +17,6 @@ import {
DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu';
import { Badge } from '../../components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../components/ui/table';
import api from '../../utils/api';
import { toast } from 'sonner';
import {
@@ -193,8 +184,15 @@ const AdminDonations = () => {
toast.error('Failed to copy to clipboard');
}
};
/*
*/
const getStatusBadgeVariant = (status) => {
const variants = {
completed: 'default',
pending: 'secondary',
failed: 'destructive'
};
return variants[status] || 'outline';
};
const getTypeBadgeColor = (type) => {
return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-brand-purple ';
@@ -394,37 +392,51 @@ const AdminDonations = () => {
{/* Donations Table */}
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Donor</TableHead>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
<TableHead>Payment Method</TableHead>
<TableHead className="text-center">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<table className="w-full">
<thead className="bg-[var(--lavender-300)] border-b-2 border-[var(--neutral-800)]">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Donor
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Type
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Amount
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Status
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Date
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Method
</th>
<th className="px-6 py-4 text-center text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Details
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--neutral-800)]">
{filteredDonations.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="p-12 text-center">
<tr>
<td colSpan="7" className="px-6 py-12 text-center">
<div className="flex flex-col items-center gap-3">
<Heart className="h-12 w-12 text-[var(--neutral-800)]" />
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donations.length === 0 ? 'No donations yet' : 'No donations match your filters'}
</p>
</div>
</TableCell>
</TableRow>
</td>
</tr>
) : (
filteredDonations.map((donation) => {
const isExpanded = expandedRows.has(donation.id);
return (
<React.Fragment key={donation.id}>
<TableRow>
<TableCell>
<tr className="hover:bg-[var(--lavender-400)] transition-colors">
<td className="px-6 py-4">
<div>
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.donor_name || 'Anonymous'}
@@ -433,37 +445,39 @@ const AdminDonations = () => {
{donation.donor_email || 'No email'}
</p>
</div>
</TableCell>
<TableCell>
</td>
<td className="px-6 py-4">
<Badge
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none px-3 py-1`}
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none rounded-full px-3 py-1`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
{donation.donation_type === 'member' ? 'Member' : 'Public'}
</Badge>
</TableCell>
<TableCell>
</td>
<td className="px-6 py-4">
<p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.amount}
</p>
</TableCell>
<TableCell>
<StatusBadge status={donation.status} />
</TableCell>
<TableCell>
</td>
<td className="px-6 py-4">
<Badge variant={getStatusBadgeVariant(donation.status)} className="rounded-full">
{donation.status.charAt(0).toUpperCase() + donation.status.slice(1)}
</Badge>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-brand-purple ">
<Calendar className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(donation.created_at)}
</span>
</div>
</TableCell>
<TableCell>
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif capitalize" }}>
</td>
<td className="px-6 py-4">
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donation.payment_method || 'N/A'}
</p>
</TableCell>
<TableCell className="text-center">
</td>
<td className="px-6 py-4 text-center">
<Button
size="sm"
variant="ghost"
@@ -472,11 +486,12 @@ const AdminDonations = () => {
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</TableCell>
</TableRow>
</td>
</tr>
{/* Expandable Details Row */}
{isExpanded && (
<TableRow className="bg-[var(--lavender-400)]/30">
<TableCell colSpan={7} className="p-6">
<tr className="bg-[var(--lavender-400)]/30">
<td colSpan="7" className="px-6 py-6">
<div className="space-y-4">
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Transaction Details
@@ -586,15 +601,15 @@ const AdminDonations = () => {
</div>
</div>
</div>
</TableCell>
</TableRow>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</tbody>
</table>
</div>
</Card>

View File

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

View File

@@ -1,369 +0,0 @@
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">
<div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Tiers
</h1>
<p className="text-muted-foreground">
Configure tier names, time ranges, and badges displayed in the members directory.
</p>
</div>
<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,9 +1,10 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import {
@@ -17,26 +18,19 @@ import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircl
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteMemberDialog from '../../components/InviteMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { useMembers } from '../../hooks/use-users';
const AdminMembers = () => {
const navigate = useNavigate();
const location = useLocation();
const { hasPermission } = useAuth();
const {
users,
filteredUsers,
loading,
searchQuery,
setSearchQuery,
filterValue: statusFilter,
setFilterValue: setStatusFilter,
refetch,
} = useMembers();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('active');
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
const [statusChanging, setStatusChanging] = useState(null);
@@ -47,13 +41,53 @@ const AdminMembers = () => {
const [importDialogOpen, setImportDialogOpen] = 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) => {
setSelectedUserForPayment(user);
setPaymentDialogOpen(true);
};
const handlePaymentSuccess = () => {
refetch(); // Refresh list
fetchMembers(); // Refresh list
};
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
@@ -74,7 +108,7 @@ const AdminMembers = () => {
try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success('Member status updated successfully');
refetch(); // Refresh list
fetchMembers(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update status');
} finally {
@@ -166,6 +200,27 @@ 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 emailReminders = user.email_verification_reminders_sent || 0;
const eventReminders = user.event_attendance_reminders_sent || 0;
@@ -189,7 +244,7 @@ const AdminMembers = () => {
return (
<>
<div className="mb-8">
<div className="flex flex-col md:flex-row justify-between items-start mb-4">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Management
@@ -198,7 +253,7 @@ const AdminMembers = () => {
Manage paying members and their subscriptions.
</p>
</div>
<div className="flex gap-3 flex-wrap ">
<div className="flex gap-3 flex-wrap">
{hasPermission('users.export') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -270,6 +325,15 @@ const AdminMembers = () => {
Quick Overview
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="Total Members"
value={users.length}
icon={Users}
iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
dataTestId="stat-total-members"
/>
<StatCard
title="Active"
value={users.filter(u => u.status === 'active').length}
@@ -291,13 +355,9 @@ const AdminMembers = () => {
iconBgClass=" text-brand-pink"
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>
@@ -323,9 +383,11 @@ const AdminMembers = () => {
<SelectItem value="active">Active</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
</SelectContent>
</Select>
</div>
@@ -339,8 +401,7 @@ const AdminMembers = () => {
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => {
const joinedDate = user.created_at;
const memberDate = user.member_since;
const joinedDate = user.member_since || user.created_at;
return (
<Card
key={user.id}
@@ -360,13 +421,12 @@ const AdminMembers = () => {
<h3 className="text-xl font-semibold text-[var(--purple-ink)] " style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</h3>
<StatusBadge status={user.status} />
{getStatusBadge(user.status)}
</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" }}>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Registered: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
<p>Member Since: {memberDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
<p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
{user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p>
)}
@@ -518,19 +578,19 @@ const AdminMembers = () => {
<CreateMemberDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={refetch}
onSuccess={fetchMembers}
/>
<InviteMemberDialog
<InviteStaffDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
onSuccess={refetch}
onSuccess={fetchMembers}
/>
<WordPressImportWizard
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
onSuccess={refetch}
onSuccess={fetchMembers}
/>
</>
);

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -16,13 +16,11 @@ export default function AdminSettings() {
// Form state
const [formData, setFormData] = useState({
publishable_key: '',
secret_key: '',
webhook_secret: ''
});
// Show/hide sensitive values
const [showPublishableKey, setShowPublishableKey] = useState(false);
const [showSecretKey, setShowSecretKey] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
@@ -59,7 +57,6 @@ export default function AdminSettings() {
const handleEditClick = () => {
setIsEditing(true);
setFormData({
publishable_key: '',
secret_key: '',
webhook_secret: ''
});
@@ -68,24 +65,17 @@ export default function AdminSettings() {
const handleCancelEdit = () => {
setIsEditing(false);
setFormData({
publishable_key: '',
secret_key: '',
webhook_secret: ''
});
setShowPublishableKey(false);
setShowSecretKey(false);
setShowWebhookSecret(false);
};
const handleSave = async () => {
// Validate inputs
if (!formData.publishable_key || !formData.secret_key || !formData.webhook_secret) {
toast.error('All three keys are required: Publishable Key, Secret Key, and Webhook Secret');
return;
}
if (!formData.publishable_key.startsWith('pk_test_') && !formData.publishable_key.startsWith('pk_live_')) {
toast.error('Invalid Publishable Key format. Must start with pk_test_ or pk_live_');
if (!formData.secret_key || !formData.webhook_secret) {
toast.error('Both Secret Key and Webhook Secret are required');
return;
}
@@ -99,25 +89,15 @@ export default function AdminSettings() {
return;
}
// Check environment consistency
const pkIsLive = formData.publishable_key.startsWith('pk_live_');
const skIsLive = formData.secret_key.startsWith('sk_live_');
if (pkIsLive !== skIsLive) {
toast.error('Publishable Key and Secret Key must be from the same environment (both test or both live)');
return;
}
setSaving(true);
try {
await api.put('/admin/settings/stripe', formData);
toast.success('Stripe settings updated successfully');
setIsEditing(false);
setFormData({
publishable_key: '',
secret_key: '',
webhook_secret: ''
});
setShowPublishableKey(false);
setShowSecretKey(false);
setShowWebhookSecret(false);
// Refresh status
@@ -146,7 +126,18 @@ export default function AdminSettings() {
}
return (
<div className="space-y-6">
<div className="container mx-auto p-6 max-w-4xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<SettingsIcon className="h-8 w-8 text-brand-purple" />
Settings
</h1>
<p className="text-gray-600 mt-2">
Manage system configuration and integrations
</p>
</div>
{/* Stripe Integration Card */}
<Card className="border-2 border-[var(--lavender-200)] shadow-sm">
<CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]">
@@ -177,31 +168,6 @@ export default function AdminSettings() {
{isEditing ? (
/* Edit Mode */
<div className="space-y-6">
{/* Publishable Key Input */}
<div className="space-y-2">
<Label htmlFor="publishable_key">Stripe Publishable Key</Label>
<div className="relative">
<Input
id="publishable_key"
type={showPublishableKey ? 'text' : 'password'}
value={formData.publishable_key}
onChange={(e) => setFormData({ ...formData, publishable_key: e.target.value })}
placeholder="pk_test_... or pk_live_..."
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPublishableKey(!showPublishableKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showPublishableKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-gray-500">
Get this from your Stripe Dashboard Developers API keys (Publishable key)
</p>
</div>
{/* Secret Key Input */}
<div className="space-y-2">
<Label htmlFor="secret_key">Stripe Secret Key</Label>
@@ -223,7 +189,7 @@ export default function AdminSettings() {
</button>
</div>
<p className="text-xs text-gray-500">
Get this from your Stripe Dashboard Developers API keys (Secret key)
Get this from your Stripe Dashboard Developers API keys
</p>
</div>
@@ -312,7 +278,7 @@ export default function AdminSettings() {
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Environment</p>
<p className="text-sm text-gray-600">Detected from key prefixes</p>
<p className="text-sm text-gray-600">Detected from secret key prefix</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
stripeStatus.environment === 'live'
@@ -323,20 +289,6 @@ export default function AdminSettings() {
</span>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Publishable Key</p>
<p className="text-sm text-gray-600 font-mono">
{stripeStatus.publishable_key_prefix}...
</p>
</div>
{stripeStatus.publishable_key_set ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<AlertCircle className="h-5 w-5 text-amber-600" />
)}
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Secret Key</p>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,743 +0,0 @@
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,9 +10,7 @@ import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera,
import { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import ChangeRoleDialog from '../../components/ChangeRoleDialog';
import StatusBadge from '../../components/StatusBadge';
import TransactionHistory from '../../components/TransactionHistory';
import AdminPaymentMethodsPanel from '../../components/admin/AdminPaymentMethodsPanel';
const AdminUserView = () => {
const { userId } = useParams();
@@ -281,7 +279,7 @@ const AdminUserView = () => {
if (loading) return <div>Loading...</div>;
if (!user) return null;
const joinedDate = user.created_at;
const joinedDate = user.member_since || user.created_at;
const memberSinceBaseline = formatDateInputValue(user.member_since);
const memberSinceHasChanges = memberSince !== memberSinceBaseline;
@@ -315,9 +313,8 @@ const AdminUserView = () => {
{user.first_name} {user.last_name}
</h1>
{/* Status & Role Badges */}
<StatusBadge status={user.status} />
<StatusBadge status={user.role} />
<Badge>{user.status}</Badge>
<Badge>{user.role}</Badge>
</div>
{/* Contact Info */}
@@ -336,7 +333,7 @@ const AdminUserView = () => {
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Registered: {formatDateDisplayValue(joinedDate)}</span>
<span>Joined {formatDateDisplayValue(joinedDate)}</span>
</div>
</div>
</div>
@@ -418,14 +415,6 @@ const AdminUserView = () => {
</div>
</Card>
{/* Payment Methods Panel */}
<div className="mb-8">
<AdminPaymentMethodsPanel
userId={userId}
userName={`${user.first_name} ${user.last_name}`}
/>
</div>
{/* Additional Details */}
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>

View File

@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import {
Select,
@@ -18,12 +20,6 @@ import {
TableRow,
TableCell,
} from '../../components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../../components/ui/tooltip';
import {
Pagination,
PaginationContent,
@@ -34,27 +30,10 @@ import {
PaginationEllipsis,
} from '../../components/ui/pagination';
import { toast } from 'sonner';
import {
CheckCircle,
Clock,
Search,
ArrowUp,
ArrowDown,
X,
FileText,
XCircle,
Users,
Mail,
ShieldCheck,
CreditCard
} from 'lucide-react';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import RejectionDialog from '../../components/RejectionDialog';
import StatusBadge from '@/components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { Button } from '@/components/ui/button';
import ViewRegistrationDialog from '@/components/ViewRegistrationDialog';
const AdminValidations = () => {
const { hasPermission } = useAuth();
@@ -68,8 +47,6 @@ const AdminValidations = () => {
const [pendingAction, setPendingAction] = useState(null);
const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false);
const [userToReject, setUserToReject] = useState(null);
const [viewRegistrationDialogOpen, setViewRegistrationDialogOpen] = useState(false);
const [selectedUserForView, setSelectedUserForView] = useState(null);
// Filtering state
const [searchQuery, setSearchQuery] = useState('');
@@ -83,9 +60,6 @@ const AdminValidations = () => {
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
// Resend email state
const [resendLoading, setResendLoading] = useState(null);
useEffect(() => {
fetchPendingUsers();
}, []);
@@ -261,26 +235,23 @@ const AdminValidations = () => {
}
};
const handleRegistrationDialog = (user) => {
setSelectedUserForView(user);
setViewRegistrationDialogOpen(true);
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
pre_validated: { label: 'Pre-Validated', className: 'bg-[var(--green-light)] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
rejected: { label: 'Rejected', className: 'bg-red-100 text-red-700' }
};
// 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 statusConfig = config[status];
return (
<Badge className={`${statusConfig.className} px-2 py-1 rounded-full text-xs`}>
{statusConfig.label}
</Badge>
);
};
const handleSort = (column) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
@@ -304,37 +275,6 @@ const AdminValidations = () => {
<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 (
<>
{/* Header */}
@@ -347,54 +287,45 @@ const AdminValidations = () => {
</p>
</div>
{/* Stats Card */}
<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>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<StatCard
title="Awaiting Email"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
icon={Mail}
iconBgClass="text-brand-pink"
dataTestId="stat-total-users"
/>
<StatCard
title="Pending Validation"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
icon={ShieldCheck}
iconBgClass="text-success"
dataTestId="stat-pending-validation"
/>
<StatCard
title="Payment Pending"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
icon={CreditCard}
iconBgClass="text-accent"
dataTestId="stat-payment-pending"
/>
<StatCard
title="Rejected"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'rejected').length}
icon={XCircle}
iconBgClass="text-red-600"
dataTestId="stat-rejected"
/>
<StatCard
title="Total Pending"
value={loading ? '-' : pendingUsers.filter(user => ['pending_email', 'pending_validation', 'pre_validated', 'payment_pending',].includes(user.status)).length}
icon={Users}
iconBgClass="text-brand-purple"
dataTestId="stat-total-users"
/>
<div>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Pending</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.length}
</p>
</div>
<div>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Awaiting Email</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.filter(u => u.status === 'pending_email').length}
</p>
</div>
<div>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.filter(u => u.status === 'pending_validation').length}
</p>
</div>
<div>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.filter(u => u.status === 'pre_validated').length}
</p>
</div>
<div>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.filter(u => u.status === 'payment_pending').length}
</p>
</div>
<div>
<p className="text-sm text-red-600 mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Rejected</p>
<p className="text-3xl font-semibold text-red-800" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.filter(u => u.status === 'rejected').length}
</p>
</div>
</div>
</Card>
@@ -415,12 +346,13 @@ const AdminValidations = () => {
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent className="">
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email" >Awaiting Email</SelectItem>
<SelectItem value="pending_validation" >Pending Validation</SelectItem>
<SelectItem value="payment_pending" >Payment Pending</SelectItem>
<SelectItem value="rejected" >Rejected</SelectItem>
<SelectItem value="pending_email">Awaiting Email</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
@@ -436,13 +368,14 @@ const AdminValidations = () => {
<Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden">
<Table>
<TableHeader>
<TableRow className="text-md">
<TableRow>
<TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
onClick={() => handleSort('first_name')}
>
Member {renderSortIcon('first_name')}
Name {renderSortIcon('first_name')}
</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
@@ -456,13 +389,6 @@ const AdminValidations = () => {
>
Registered {renderSortIcon('created_at')}
</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>Actions</TableHead>
</TableRow>
@@ -470,116 +396,105 @@ const AdminValidations = () => {
<TableBody>
{paginatedUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className=" ">
<div className='font-semibold'>
{user.first_name} {user.last_name}
</div>
<div className='text-brand-purple'>
{user.email}
</div>
<TableCell className="font-medium">
{user.first_name} {user.last_name}
</TableCell>
<TableCell>{formatPhoneNumber(user.phone)}</TableCell>
<TableCell><StatusBadge status={user.status} /></TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>{getStatusBadge(user.status)}</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
{user.email_verification_expires_at
? new Date(user.email_verification_expires_at).toLocaleString()
: '—'}
</TableCell>
<TableCell>
{user.referred_by_member_name || '-'}
</TableCell>
<TableCell>
<div className='flex gap-2 justify-between'>
<Select
value=""
onValueChange={(action) => handleActionSelect(user, action)}
disabled={actionLoading === user.id || resendLoading === user.id}
>
<SelectTrigger className="w-[100px] h-9 border-[var(--neutral-800)]">
<SelectValue placeholder={actionLoading === user.id || resendLoading === user.id ? 'Processing...' : '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>
<TooltipProvider>
{/* view registration */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex gap-2">
{user.status === 'rejected' ? (
<Button
onClick={() => handleReactivateUser(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]"
>
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'}
</Button>
) : user.status === 'pending_email' ? (
<>
{hasPermission('users.approve') && (
<Button
onClick={() => handleRegistrationDialog(user)}
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-primary text-primary hover:bg-red-50 dark:hover:bg-red-500/10"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
>
<FileText className="size-4" />
<X className="h-4 w-4 mr-1" />
Reject
</Button>
</TooltipTrigger>
<TooltipContent>
View registration
</TooltipContent>
</Tooltip>
{/* reject */}
{hasPermission('users.approve') && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 mr-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
>
X
</Button>
</TooltipTrigger>
<TooltipContent>
Reject user
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
)}
</>
) : user.status === 'payment_pending' ? (
<>
{hasPermission('subscriptions.activate') && (
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="btn-light-lavender"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
) : (
<>
{hasPermission('users.approve') && (
<Button
onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]"
>
{actionLoading === user.id ? 'Validating...' : 'Validate'}
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
)}
</div>
</TableCell>
</TableRow>
@@ -704,13 +619,6 @@ const AdminValidations = () => {
user={userToReject}
loading={actionLoading !== null}
/>
{/* View Registration Dialog */}
<ViewRegistrationDialog
open={viewRegistrationDialogOpen}
onOpenChange={setViewRegistrationDialogOpen}
user={selectedUserForView}
/>
</>
);
};

View File

@@ -5,34 +5,20 @@ import MemberFooter from '../../components/MemberFooter';
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 { toast } from 'sonner';
import { Scale, ExternalLink, History, Check, Search, Calendar } from 'lucide-react';
import { Scale, ExternalLink, History, Check } from 'lucide-react';
export default function Bylaws() {
const [currentBylaws, setCurrentBylaws] = useState(null);
const [history, setHistory] = useState([]);
const [years, setYears] = useState([]);
const [selectedYear, setSelectedYear] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [showHistory, setShowHistory] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCurrentBylaws();
fetchYears();
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 () => {
try {
const response = await api.get('/bylaws/current');
@@ -46,46 +32,15 @@ export default function Bylaws() {
}
};
const fetchHistory = async (year = null) => {
const fetchHistory = async () => {
try {
const url = year ? `/bylaws/history?year=${year}` : '/bylaws/history';
const response = await api.get(url);
const response = await api.get('/bylaws/history');
setHistory(response.data);
} catch (error) {
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) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
@@ -120,44 +75,6 @@ export default function Bylaws() {
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Review the official governing bylaws and policies of the LOAF community.
</p>
{/* 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>
{/* Current Bylaws */}
@@ -207,7 +124,7 @@ export default function Bylaws() {
)}
{/* Version History Toggle */}
{filteredHistory.filter(b => !b.is_current).length > 0 && (
{history.length > 1 && (
<div className="mb-6">
<Button
onClick={() => setShowHistory(!showHistory)}
@@ -215,48 +132,41 @@ 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"
>
<History className="h-4 w-4" />
{showHistory ? 'Hide' : 'View'} Version History ({filteredHistory.filter(b => !b.is_current).length} previous {filteredHistory.filter(b => !b.is_current).length === 1 ? 'version' : 'versions'})
{showHistory ? 'Hide' : 'View'} Version History ({history.length - 1} previous {history.length - 1 === 1 ? 'version' : 'versions'})
</Button>
</div>
)}
{/* Version History */}
{showHistory && filteredHistory.filter(b => !b.is_current).length > 0 && (
<div className="space-y-6">
{sortedYears.map(year => (
<div key={year}>
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Calendar className="h-5 w-5" />
{year}
</h3>
<div className="space-y-4">
{groupedHistory[year].map(bylaws => (
<Card key={bylaws.id} className="p-6 bg-[var(--lavender-600)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<h4 className="text-lg font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{bylaws.title}
</h4>
<div className="flex items-center gap-3 text-sm text-brand-purple ">
<span>Version {bylaws.version}</span>
<span></span>
<span>Effective {formatDate(bylaws.effective_date)}</span>
</div>
</div>
<Button
onClick={() => window.open(bylaws.document_url, '_blank')}
variant="outline"
size="sm"
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>
))}
{showHistory && history.length > 1 && (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Previous Versions
</h3>
{history.filter(b => !b.is_current).map(bylaws => (
<Card key={bylaws.id} className="p-6 bg-[var(--lavender-600)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<h4 className="text-lg font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{bylaws.title}
</h4>
<div className="flex items-center gap-3 text-sm text-brand-purple ">
<span>Version {bylaws.version}</span>
<span></span>
<span>Effective {formatDate(bylaws.effective_date)}</span>
</div>
</div>
<Button
onClick={() => window.open(bylaws.document_url, '_blank')}
variant="outline"
size="sm"
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>
</div>
</Card>
))}
</div>
)}

View File

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

View File

@@ -5,36 +5,20 @@ import MemberFooter from '../../components/MemberFooter';
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 { toast } from 'sonner';
import { DollarSign, ExternalLink, TrendingUp, Search, Calendar } from 'lucide-react';
import { DollarSign, ExternalLink, TrendingUp } from 'lucide-react';
export default function Financials() {
const [reports, setReports] = useState([]);
const [years, setYears] = useState([]);
const [selectedYear, setSelectedYear] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchYears();
fetchReports();
}, []);
const fetchYears = async () => {
const fetchReports = async () => {
try {
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);
const response = await api.get('/financials');
setReports(response.data);
} catch (error) {
toast.error('Failed to load financial reports');
@@ -43,36 +27,6 @@ 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) {
return (
<div className="min-h-screen bg-background">
@@ -99,100 +53,56 @@ export default function Financials() {
<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.
</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>
{/* Reports List */}
{filteredReports.length === 0 ? (
{reports.length === 0 ? (
<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" />
<p className="text-brand-purple text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No financial reports found
No financial reports available yet
</p>
</Card>
) : (
<div className="space-y-8">
{sortedYears.map(year => (
<div key={year}>
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Calendar className="h-6 w-6" />
{year}
</h2>
<div className="space-y-6">
{groupedReports[year].map(report => (
<Card key={report.id} className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow">
<div className="flex items-center gap-6">
{/* 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>
<div className="space-y-6">
{reports.map(report => (
<Card key={report.id} className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow">
<div className="flex items-center gap-6">
{/* Year Badge */}
<div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-6 rounded-xl text-white min-w-[120px] text-center">
<DollarSign className="h-8 w-8 mx-auto mb-2" />
<div className="text-3xl font-bold" style={{ fontFamily: "'Inter', sans-serif" }}>
{report.year}
</div>
<div className="text-sm opacity-90">Fiscal Year</div>
</div>
{/* Report Details */}
<div className="flex-1">
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{report.title}
</h3>
<div className="flex items-center gap-2 mb-4">
<Badge variant="outline" className="border-brand-purple text-brand-purple ">
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
</Badge>
</div>
<Button
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"
>
<ExternalLink className="h-4 w-4" />
View Report
</Button>
</div>
</div>
</Card>
))}
{/* Report Details */}
<div className="flex-1">
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{report.title}
</h3>
<div className="flex items-center gap-2 mb-4">
<Badge variant="outline" className="border-brand-purple text-brand-purple ">
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
</Badge>
</div>
<Button
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"
>
<ExternalLink className="h-4 w-4" />
View Report
</Button>
</div>
</div>
</div>
</Card>
))}
</div>
)}
{/* Transparency Note */}
{filteredReports.length > 0 && (
{reports.length > 0 && (
<Card className="mt-8 p-6 bg-[var(--lavender-600)] border border-[var(--neutral-800)]">
<div className="flex items-start gap-3">
<TrendingUp className="h-5 w-5 text-brand-purple mt-1" />

View File

@@ -1,8 +1,7 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Link } from 'react-router-dom';
import { Card } from '../../components/ui/card';
import { Input } from '../../components/ui/input';
import { Badge } from '../../components/ui/badge';
@@ -16,91 +15,74 @@ import {
} from '../../components/ui/dialog';
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
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';
import useDirectoryConfig from '../../hooks/use-directory-config';
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 [profileDialogOpen, setProfileDialogOpen] = useState(false);
const { toast } = useToast();
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12;
const { tiers } = useMemberTiers();
const { isFieldEnabled } = useDirectoryConfig();
const allowedRoles = useMemo(() => [], []);
const normalizeStatus = useCallback((status) => {
if (typeof status === 'string') {
return status.toLowerCase();
}
return status;
useEffect(() => {
fetchMembers();
}, []);
const normalizeMembers = useCallback(
(data) => {
const list = Array.isArray(data)
? data
: data?.members || data?.results || data?.items || data?.data || [];
return list.map((member) => ({
...member,
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(() => {
filterMembers();
}, [searchQuery, members]);
useEffect(() => {
setCurrentPage(1);
}, [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 pageStart = (currentPage - 1) * pageSize;
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
const totalMembers = useMemo(() => {
if (!filterValue || filterValue === 'all') {
return members.length;
}
return members.filter((member) => member.status === filterValue).length;
}, [members, filterValue]);
const totalMembers = members.length;
const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
};
const getSocialMediaLink = (url) => {
if (!url) return null;
@@ -136,9 +118,171 @@ const MembersDirectory = () => {
: <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 (
<div className="min-h-screen bg-gradient-to-bl from-white to-muted">
<div className="min-h-screen bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)]">
<Navbar />
<div className="max-w-7xl mx-auto py-12">
@@ -152,12 +296,12 @@ const MembersDirectory = () => {
LOAF Members
</h1>
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span className='text-foreground'>Number of current members in the directory: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span>
<span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span>
</p>
</div>
{/* Search Bar */}
<div className="mb-24 w-full">
<div className="mb-24 mx-10">
<div className="relative w-full ">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input
@@ -189,7 +333,7 @@ const MembersDirectory = () => {
) : filteredMembers.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{paginatedMembers.map((member) => (
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} tiers={tiers} />
<MemberCard key={member.id} member={member} />
))}
</div>
) : (
@@ -210,7 +354,7 @@ const MembersDirectory = () => {
{/* Border Decoration */}
<Border yaxis="true" />
{/* todo: use badge to display if member */}
{/* Info Card */}
{!loading && members.length > 0 && (
<Card className="mt-12 p-6 bg-[var(--lavender-500)] border-[var(--neutral-800)]">
@@ -224,27 +368,26 @@ const MembersDirectory = () => {
</h3>
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '}
<Link to="/profile" className="text-[var(--orange-light)] hover:underline font-medium">
<a href="/members/profile" className="text-[var(--orange-light)] hover:underline font-medium">
Edit your profile
</Link>
</a>
</p>
</div>
</div>
</Card>
)}
</div>
{/* Profile Detail Dialog */}
<Dialog open={profileDialogOpen} onOpenChange={setProfileDialogOpen}>
<DialogContent className="sm:max-w-[600px] bg-background rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
{selectedMember && (
<>
<DialogHeader>
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedMember.first_name} {selectedMember.last_name}
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
</DialogTitle>
{isFieldEnabled('directory_partner_name') && selectedMember.directory_partner_name && (
{selectedMember.directory_partner_name && (
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
<span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span>
@@ -254,7 +397,7 @@ const MembersDirectory = () => {
<div className="space-y-6 py-4">
{/* Bio */}
{isFieldEnabled('directory_bio') && selectedMember.directory_bio && (
{selectedMember.directory_bio && (
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
About
@@ -266,81 +409,79 @@ const MembersDirectory = () => {
)}
{/* Contact Information */}
{(isFieldEnabled('directory_email') || isFieldEnabled('directory_phone') || isFieldEnabled('directory_address') || isFieldEnabled('directory_dob')) && (
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Contact Information
</h3>
<div className="space-y-3">
{isFieldEnabled('directory_email') && selectedMember.directory_email && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Mail className="h-5 w-5 text-brand-purple " />
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<a
href={`mailto:${selectedMember.directory_email}`}
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{selectedMember.directory_email}
</a>
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Contact Information
</h3>
<div className="space-y-3">
{selectedMember.directory_email && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Mail className="h-5 w-5 text-brand-purple " />
</div>
)}
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<a
href={`mailto:${selectedMember.directory_email}`}
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{selectedMember.directory_email}
</a>
</div>
</div>
)}
{isFieldEnabled('directory_phone') && selectedMember.directory_phone && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Phone className="h-5 w-5 text-brand-purple " />
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Phone</p>
<a
href={`tel:${selectedMember.directory_phone}`}
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{selectedMember.directory_phone}
</a>
</div>
{selectedMember.directory_phone && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Phone className="h-5 w-5 text-brand-purple " />
</div>
)}
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Phone</p>
<a
href={`tel:${selectedMember.directory_phone}`}
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{selectedMember.directory_phone}
</a>
</div>
</div>
)}
{isFieldEnabled('directory_address') && selectedMember.directory_address && (
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<MapPin className="h-5 w-5 text-brand-purple " />
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedMember.directory_address}
</p>
</div>
{selectedMember.directory_address && (
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<MapPin className="h-5 w-5 text-brand-purple " />
</div>
)}
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedMember.directory_address}
</p>
</div>
</div>
)}
{isFieldEnabled('directory_dob') && selectedMember.directory_dob && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Birthday</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(selectedMember.directory_dob)}
</p>
</div>
{selectedMember.directory_dob && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
</div>
)}
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Birthday</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(selectedMember.directory_dob)}
</p>
</div>
</div>
)}
</div>
)}
</div>
{/* Volunteer Interests */}
{isFieldEnabled('volunteer_interests') && selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
{selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests
@@ -359,7 +500,7 @@ const MembersDirectory = () => {
)}
{/* Social Media */}
{isFieldEnabled('social_media') && (selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -422,6 +563,8 @@ const MembersDirectory = () => {
</DialogContent>
</Dialog>
{/* Pagination */}
{!loading && filteredMembers.length > 0 && (
<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 => (
<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="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl">
<FileText className="h-8 w-8 text-white" />
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg flex-shrink-0">
<FileText className="h-6 w-6 text-brand-purple " />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>

View File

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

View File

@@ -2,6 +2,32 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 280 47% 27%;
--card: 0 0% 100%;
--card-foreground: 280 47% 27%;
--popover: 0 0% 100%;
--popover-foreground: 280 47% 27%;
--primary: 280 47% 27%;
--primary-foreground: 0 0% 100%;
--secondary: 268 33% 89%;
--secondary-foreground: 280 47% 27%;
--muted: 268 43% 95%;
--muted-foreground: 268 35% 47%;
--accent: 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
========================= */
@@ -21,7 +47,7 @@
/*
==========================
Social Media Colors
Color Patch
==========================
*/
@@ -29,50 +55,6 @@
--blue-facebook: #1877f2;
--blue-twitter: #1da1f2;
--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-2: #422268;
--purple-deep: #48286e;

View File

@@ -30,33 +30,6 @@ api.interceptors.response.use(
async (error) => {
const config = error.config;
// Handle 401 Unauthorized - session expired
if (error.response && error.response.status === 401) {
// Don't redirect if it's a login request or auth check
const isAuthRequest = config?.url?.includes('/auth/login') ||
config?.url?.includes('/auth/me') ||
config?.url?.includes('/auth/permissions');
if (!isAuthRequest) {
console.warn('[API] Session expired - redirecting to login');
// Clear auth state
localStorage.removeItem('token');
// Dispatch custom event for components to react
window.dispatchEvent(new CustomEvent('auth:session-expired'));
// Redirect to login with session expired message
// Use replace to prevent back button issues
const currentPath = window.location.pathname;
if (!currentPath.includes('/login')) {
window.location.replace('/login?session=expired');
}
}
return Promise.reject(error);
}
// Don't retry if we've already retried or if it's a client error (4xx)
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
console.error('[API] Request failed:', {

View File

@@ -1,56 +0,0 @@
// 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,10 +49,6 @@ module.exports = {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))'
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
@@ -105,6 +101,6 @@ module.exports = {
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
require("@tailwindcss/typography")
],
};

View File

@@ -2552,11 +2552,6 @@
"@svgr/plugin-svgo" "^5.5.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":
version "0.5.19"
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.19.tgz#ecb734af2569681eb40932f09f60c2848b909456"