50 Commits

Author SHA1 Message Date
kayela
7eef62560e feat: staff can edit registration responses 2026-01-29 21:49:25 -06:00
kayela
f70a133e18 feat: tabs layout for edit profile 2026-01-29 20:49:13 -06:00
kayela
d5152609b6 Donation status badge upates, admin validation tootips 2026-01-29 19:37:41 -06:00
kayela
de719d9d69 refactor subscriptionsTable.jsx 2026-01-29 18:46:16 -06:00
kayela
27d5c48805 Componentized subscription table 2026-01-29 18:36:13 -06:00
kayela
64d631d890 Members -removed un used selection options. Profile added back button 2026-01-29 18:12:48 -06:00
kayela
a77fbc47e3 styling improvements 2026-01-28 19:08:13 -06:00
kayela
d638afcdb2 add column for email expiry date
Members > Invite member says invite Staff in dialog
 resend email button
 Update form member form to say member and not staff
 review application function
 manual payment functionality
 basic implementation of theme
 actions dropdown
2026-01-28 18:59:19 -06:00
kayela
a247ac5219 feat: added theme success and warning colors
fix: invite member dialog box
feat: email expiry date column
feat: resend email verification button
fix: select item text can be seen
2026-01-28 15:03:46 -06:00
kayela
01722edad9 updated badge glitch 2026-01-27 17:30:50 -06:00
kayela
378b909398 removed transaction history from Profile.js 2026-01-27 16:38:21 -06:00
kayela
4ad1997bd5 Member tiers implementation intact. Icons updated to be Lucide React. Create/edit member tiers. Display member badge. Transaction history now in My profile dashboard. Adjusted Icons for badges. Added badges on my profile page 2026-01-27 16:30:26 -06:00
kayela
0d7e3a1286 tweaked statcard for better styling when digits are greater than 2 2026-01-27 15:19:19 -06:00
kayela
0c3d4a4edd Updated stat cards to be consistent with rest of codebase 2026-01-27 15:11:25 -06:00
kayela
97aa7860a9 feat: integrate TransactionHistory component into Dashboard and update styles for better UI consistency 2026-01-27 14:33:36 -06:00
Koncept Kit
467f34b42a - - New ThemeConfigContext provider that fetches theme on app load and applies it to the DOM (title, meta description, favicon, CSS variables,
theme-color)/- - Admin Theme settings page under Settings > Theme tab/- All logo references (5 components) now pull from the theme config with fallback to default
2026-01-27 21:32:22 +07:00
Koncept Kit
85070cf77b Update Footer to get current year 2026-01-27 16:44:56 +07:00
9dcb8e3185 Merge pull request 'theme-provider' (#20) from theme-provider into dev
Reviewed-on: #20
2026-01-27 08:41:49 +00:00
kayela
a88388ed5d Merge branch 'dev' into theme-provider 2026-01-27 00:53:27 -06:00
kayela
91e264bf7a feat: enhance Dashboard with transaction history and membership info; refactor layout for improved user experience 2026-01-27 00:32:14 -06:00
kayela
333ce62710 feat: add year filtering and search functionality to Bylaws and Financials pages; enhance report grouping by year 2026-01-26 15:18:27 -06:00
kayela
3c0b1396bc feat: enhance dialog components with overflow handling and update placeholder text for consistency 2026-01-26 14:47:04 -06:00
kayela
1ae82fc4e4 fix: badge text styling on hover in settings 2026-01-26 14:06:45 -06:00
kayela
ac8d40112e feat: add AdminMemberTiers page, MemberBadge component, and SettingsLayout; refactor routes and sidebar for improved navigation 2026-01-26 13:58:44 -06:00
kayela
7ee5cb0d9c feat: update AdminMembers and AdminStaff components for improved statistics display and fix typo in MembersDirectory 2026-01-25 12:55:26 -06:00
kayela
4548d959d7 feat: implement UsersContext and refactor user management hooks for improved user data handling 2026-01-25 12:17:30 -06:00
Koncept Kit
002ef5c897 Fixes 2026-01-24 23:56:15 +07:00
kayela
f2dd053320 feat: enhance AdminRoles to manage permissions with loading state and role slug updates 2026-01-22 15:23:50 -06:00
kayela
554b599599 feat: refactor AdminMembers and AdminStaff to utilize useMembers hook for improved member management 2026-01-22 14:47:34 -06:00
kayela
ac879b69b4 feat: Introduce StatusBadge component for consistent status representation
- Added StatusBadge component to standardize the display of user and membership statuses across various admin pages.
- Refactored AdminMembers, AdminPlans, AdminStaff, AdminSubscriptions, AdminUserView, AdminValidations, and MembersDirectory to utilize the new StatusBadge component.
- Removed redundant status badge logic from AdminMembers, AdminStaff, and AdminValidations.
- Updated AdminLayout to include a mobile-friendly sidebar toggle button with Menu icon.
- Created MemberCard component to encapsulate member display logic, improving code reusability.
- Adjusted various components to enhance user experience and maintain consistent styling.
2026-01-22 14:20:02 -06:00
kayela
6c844c0e19 feat: add @tailwindcss/line-clamp dependency and integrate responsive layout adjustments in Admin components for improved UI 2026-01-22 12:07:56 -06:00
7d0c207f1b Merge pull request 'theme-provider' (#18) from theme-provider into dev
Reviewed-on: #18
2026-01-21 04:58:57 +00:00
kayela
8ea486a4f4 feat: enhance date formatting in AdminUserView for improved readability and consistency 2026-01-20 22:51:44 -06:00
kayela
264ee860df feat: add member since date handling in CreateMemberDialog and CreateStaffDialog for improved member tracking 2026-01-20 16:16:23 -06:00
kayela
65c3e3b92d fix: update color styles in AdminSidebar, Register, and CSS files for improved UI consistency 2026-01-20 16:02:54 -06:00
kayela
819062d697 fixed spacing in AdminMembers.js 2026-01-20 14:45:05 -06:00
kayela
c73ebfb6c0 feat: implement StatCard component and integrate it into AdminDashboard and AdminMembers for improved stats display 2026-01-20 14:43:17 -06:00
kayela
3822ba8ffb feat: add member since date handling across admin and member views 2026-01-20 12:33:17 -06:00
Koncept Kit
c79db66739 - Details Column - Expandable chevron button for each row- Expandable Transaction Details - Click chevron to show/hide details- Payment Information Section:- Stripe Transaction IDs Section- Copy to Clipboard - One-click copy for all transaction IDs- Update Stripe webhook event permission on Stripe Config page. 2026-01-20 23:52:35 +07:00
Koncept Kit
57cd18ad9d - Add Settings menu for Stripe configuration- In the Member Profile page, Superadmin can assign new Role to the member- Stripe Configuration is now stored with encryption in Database 2026-01-16 19:07:14 +07:00
56dd9eeb77 Merge pull request 'theme-provider' (#16) from theme-provider into dev
Reviewed-on: #16
2026-01-16 10:40:04 +00:00
kayela
e831835e6d Merge branch 'dev' into theme-backup 2026-01-14 15:40:45 -06:00
kayela
9287adec01 refactor: update button styles for improved theming and consistency 2026-01-14 13:59:21 -06:00
kayela
0c1202d89a refactor: add scrollbar styles to dialog components for improved usability 2026-01-14 13:43:04 -06:00
kayela
0ebfe71361 refactor: update button styles and text for improved consistency and theming 2026-01-14 13:23:52 -06:00
Koncept Kit
ee0ad176b0 Remove View Public Site on AdminSidebar 2026-01-09 00:23:52 +07:00
Koncept Kit
180eb1ce85 Comment out View Public Site link on the AdminSidebar.js 2026-01-07 15:37:40 +07:00
Koncept Kit
5377a0f465 Security Hardening 2026-01-07 14:03:32 +07:00
Koncept Kit
c54eb23689 Login and Session Fixes 2026-01-07 13:37:20 +07:00
9f7367ceeb Merge pull request 'Merge Kayela works to Dev' (#12) from templates into dev
Reviewed-on: #12
2026-01-07 06:18:06 +00:00
90 changed files with 8777 additions and 2576 deletions

75
.dockerignore Normal file
View File

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

49
Dockerfile Normal file
View File

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

179
README.md
View File

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

44
nginx.conf Normal file
View File

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

View File

@@ -36,6 +36,7 @@
"@radix-ui/react-tooltip": "^1.2.4",
"@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",

5
public/health.json Normal file
View File

@@ -0,0 +1,5 @@
{
"status": "healthy",
"mode": "production",
"build": "optimized"
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from './components/ui/sonner';
import IdleSessionWarning from './components/IdleSessionWarning';
import Landing from './pages/Landing';
import Register from './pages/Register';
import Login from './pages/Login';
@@ -21,7 +22,10 @@ import AdminUserView from './pages/admin/AdminUserView';
import AdminStaff from './pages/admin/AdminStaff';
import AdminMembers from './pages/admin/AdminMembers';
import AdminPermissions from './pages/admin/AdminPermissions';
import AdminSettings from './pages/admin/AdminSettings';
import 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';
@@ -29,6 +33,7 @@ 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';
@@ -284,16 +289,28 @@ function App() {
} />
<Route path="/admin/permissions" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminRoles />
</AdminLayout>
<Navigate to="/admin/settings/permissions" replace />
</PrivateRoute>
} />
<Route path="/admin/settings" element={
<PrivateRoute adminOnly>
<AdminLayout>
<SettingsLayout />
</AdminLayout>
</PrivateRoute>
}>
<Route index element={<Navigate to="stripe" replace />} />
<Route path="stripe" element={<AdminSettings />} />
<Route path="permissions" element={<AdminRoles />} />
<Route path="member-tiers" element={<AdminMemberTiers />} />
<Route path="theme" element={<AdminTheme />} />
</Route>
{/* 404 - Catch all undefined routes */}
<Route path="*" element={<NotFound />} />
</Routes>
<Toaster position="top-right" />
<IdleSessionWarning />
</BrowserRouter>
</AuthProvider>
);

View File

@@ -117,7 +117,7 @@ export default function AddToCalendarButton({
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size} className="gap-2">
<Button variant={variant} size={size} className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full gap-2 dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender">
<Calendar className="h-4 w-4" />
Add to Calendar
<ChevronDown className="h-4 w-4" />

View File

@@ -2,6 +2,7 @@ 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 {
@@ -32,6 +33,7 @@ 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);
@@ -169,10 +171,11 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
path: '/admin/bylaws',
disabled: false
},
{
name: 'Permissions',
icon: Shield,
path: '/admin/permissions',
name: 'Settings',
icon: Settings,
path: '/admin/settings',
disabled: false,
superadminOnly: true
}
@@ -181,11 +184,15 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
// Filter nav items based on user role
const filteredNavItems = navItems.filter(item => {
if (item.superadminOnly && user?.role !== 'superadmin') {
console.log('Filtering out superadmin-only item:', item.name, 'User role:', user?.role);
return false;
}
return true;
});
// Debug: Log filtered items count
console.log('Total nav items:', navItems.length, 'Filtered items:', filteredNavItems.length, 'User role:', user?.role);
const isActive = (path) => {
if (path === '/admin') {
return location.pathname === '/admin';
@@ -213,7 +220,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
${item.disabled
? 'opacity-50 cursor-not-allowed text-brand-purple '
: active
? 'bg-[var(--orange-light)]/10 text-[var(--orange-light)]'
? 'bg-[var(--orange-light)]/10 text-[var(--purple-ink)]'
: 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/20'
}
`}
@@ -243,7 +250,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* Badge when collapsed */}
{!isOpen && item.badge > 0 && !item.disabled && (
<div className="absolute -top-1 -right-1 bg-accent foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
<div className="absolute -top-1 -right-1 bg-accent text-white foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
{item.badge}
</div>
)}
@@ -276,7 +283,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={`${process.env.PUBLIC_URL}/loaf-logo.png`}
src={getLogoUrl()}
alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`}
@@ -286,9 +293,6 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
<h2 className="text-xl font-semibold text-primary dark:text-brand-light-lavender " style={{ fontFamily: "'Inter', sans-serif" }}>
Admin
</h2>
<p className="text-xs text-muted-foreground group-hover:text-accent transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View Public Site
</p>
</div>
)}
</Link>
@@ -367,11 +371,21 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{renderNavItem(filteredNavItems.find(item => item.name === 'Bylaws'))}
</div>
{/* Permissions - Superadmin only (no header) */}
{/* SYSTEM Section - Superadmin only */}
{user?.role === 'superadmin' && (
<div className="mt-6">
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
</div>
<>
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
System
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Settings'))}
</div>
</>
)}
</nav>

View File

@@ -55,7 +55,7 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto bg-background">
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto bg-background scrollbar-dashboard">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Mark Attendance: {event?.title}

View File

@@ -66,7 +66,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-background">
<DialogContent className="sm:max-w-md bg-background overflow-y-auto max-h-[90vh]">
<DialogHeader>
<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)]">

View File

@@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { Button } from './ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Label } from './ui/label';
import { AlertCircle, Shield } from 'lucide-react';
import api from '../utils/api';
import { toast } from 'sonner';
export default function ChangeRoleDialog({ open, onClose, user, onSuccess }) {
const [roles, setRoles] = useState([]);
const [selectedRole, setSelectedRole] = useState('');
const [selectedRoleId, setSelectedRoleId] = useState(null);
const [loadingRoles, setLoadingRoles] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
fetchRoles();
// Pre-select current role
setSelectedRole(user.role);
setSelectedRoleId(user.role_id);
}
}, [open, user]);
const fetchRoles = async () => {
setLoadingRoles(true);
try {
// Reuse existing endpoint that returns assignable roles based on privilege
const response = await api.get('/admin/roles/assignable');
// Map API response to format expected by Select component
const mappedRoles = response.data.map(role => ({
value: role.code,
label: role.name,
id: role.id,
description: role.description
}));
setRoles(mappedRoles);
} catch (error) {
console.error('Failed to fetch assignable roles:', error);
toast.error('Failed to load roles. Please try again.');
} finally {
setLoadingRoles(false);
}
};
const handleSubmit = async () => {
if (!selectedRole) {
toast.error('Please select a role');
return;
}
// Don't submit if role hasn't changed
if (selectedRole === user.role && selectedRoleId === user.role_id) {
toast.info('The selected role is the same as current role');
return;
}
setSubmitting(true);
try {
await api.put(`/admin/users/${user.id}/role`, {
role: selectedRole,
role_id: selectedRoleId
});
toast.success(`Role changed to ${selectedRole}`);
onSuccess();
onClose();
} catch (error) {
const message = error.response?.data?.detail || 'Failed to change role';
toast.error(message);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px] overflow-y-auto max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-[#664fa3]" />
Change User Role
</DialogTitle>
<DialogDescription>
Change role for {user.first_name} {user.last_name} ({user.email})
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Current Role Display */}
<div className="p-3 bg-[#f1eef9] rounded-lg border border-[#DDD8EB]">
<p className="text-sm text-gray-600">Current Role</p>
<p className="font-semibold text-[#664fa3] capitalize">{user.role}</p>
</div>
{/* Role Selection */}
<div className="space-y-2">
<Label htmlFor="role">New Role</Label>
<Select value={selectedRole} onValueChange={setSelectedRole} disabled={loadingRoles}>
<SelectTrigger>
<SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select role"} />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.value} value={role.value}>
<span className="capitalize">{role.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Warning for privileged roles */}
{(selectedRole === 'admin' || selectedRole === 'superadmin') && (
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-amber-900">Admin Access Warning</p>
<p className="text-amber-700">
This user will gain full administrative access to the system.
</p>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={submitting}
className="border-2 border-gray-300 rounded-full"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || loadingRoles}
className="bg-[#664fa3] hover:bg-[#7d5ec2] text-white rounded-full"
>
{submitting ? 'Changing Role...' : 'Change Role'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -31,6 +31,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const getTodayDate = () => new Date().toISOString().slice(0, 10);
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
@@ -84,8 +85,8 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
if (payload.date_of_birth === '') {
delete payload.date_of_birth;
}
if (payload.member_since === '') {
delete payload.member_since;
if (!payload.member_since) {
payload.member_since = getTodayDate();
}
await api.post('/admin/users/create', payload);
@@ -119,7 +120,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" />
@@ -180,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="John"
placeholder="Jane"
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>

View File

@@ -22,10 +22,12 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
first_name: '',
last_name: '',
phone: '',
member_since: '',
role: 'admin'
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const getTodayDate = () => new Date().toISOString().slice(0, 10);
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
@@ -74,7 +76,11 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
setLoading(true);
try {
await api.post('/admin/users/create', formData);
const payload = { ...formData };
if (!payload.member_since) {
payload.member_since = getTodayDate();
}
await api.post('/admin/users/create', payload);
toast.success('Staff member created successfully');
// Reset form
@@ -84,6 +90,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
first_name: '',
last_name: '',
phone: '',
member_since: '',
role: 'admin'
});
@@ -99,7 +106,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] rounded-2xl">
<DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" />
@@ -158,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="John"
placeholder="Jane"
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>
@@ -200,6 +207,20 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
)}
</div>
{/* Member Since */}
<div className="grid gap-2">
<Label htmlFor="member_since" className="text-[var(--purple-ink)]">
Member Since
</Label>
<Input
id="member_since"
type="date"
value={formData.member_since}
onChange={(e) => handleChange('member_since', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/>
</div>
{/* Role */}
<div className="grid gap-2">
<Label htmlFor="role" className="text-[var(--purple-ink)]">

View File

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

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import logger from '../utils/logger';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { AlertTriangle, RefreshCw } from 'lucide-react';
/**
* IdleSessionWarning Component
*
* Monitors user activity and warns before session expiration
* - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
* - Auto-logout on expiration
* - "Stay Logged In" extends session
*/
const IdleSessionWarning = () => {
const { user, logout, refreshUser } = useAuth();
const navigate = useNavigate();
// Configuration
const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
const WARNING_BEFORE_EXPIRY = 1 * 60 * 1000; // Warn 1 minute before expiry
const WARNING_TIME = SESSION_DURATION - WARNING_BEFORE_EXPIRY; // 29 minutes
const [showWarning, setShowWarning] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(60); // seconds
const [isExtending, setIsExtending] = useState(false);
const activityTimeoutRef = useRef(null);
const warningTimeoutRef = useRef(null);
const countdownIntervalRef = useRef(null);
const lastActivityRef = useRef(Date.now());
// Reset activity timer
const resetActivityTimer = useCallback(() => {
lastActivityRef.current = Date.now();
// Clear existing timers
if (activityTimeoutRef.current) {
clearTimeout(activityTimeoutRef.current);
}
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
// Hide warning if showing
if (showWarning) {
setShowWarning(false);
}
// Set new warning timer
warningTimeoutRef.current = setTimeout(() => {
// Show warning
setShowWarning(true);
setTimeRemaining(60); // 60 seconds until logout
// Start countdown
countdownIntervalRef.current = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1) {
// Time's up - logout
handleSessionExpired();
return 0;
}
return prev - 1;
});
}, 1000);
// Set auto-logout timer
activityTimeoutRef.current = setTimeout(() => {
handleSessionExpired();
}, WARNING_BEFORE_EXPIRY);
}, WARNING_TIME);
}, [showWarning]);
// Handle session expiration
const handleSessionExpired = useCallback(() => {
// Clear all timers
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
setShowWarning(false);
logout();
navigate('/login', {
state: { message: 'Your session has expired due to inactivity. Please log in again.' }
});
}, [logout, navigate]);
// Handle "Stay Logged In" button
const handleExtendSession = async () => {
setIsExtending(true);
try {
// Refresh user data to get new token
await refreshUser();
// Reset activity timer
resetActivityTimer();
logger.log('[IdleSessionWarning] Session extended successfully');
} catch (error) {
logger.error('[IdleSessionWarning] Failed to extend session:', error);
// If refresh fails, logout
handleSessionExpired();
} finally {
setIsExtending(false);
}
};
// Track user activity
useEffect(() => {
if (!user) return;
const activityEvents = [
'mousedown',
'mousemove',
'keypress',
'scroll',
'touchstart',
'click'
];
// Throttle activity detection to avoid too many resets
let throttleTimeout = null;
const handleActivity = () => {
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
resetActivityTimer();
throttleTimeout = null;
}, 1000); // Throttle to once per second
};
// Add event listeners
activityEvents.forEach(event => {
document.addEventListener(event, handleActivity, { passive: true });
});
// Initialize timer
resetActivityTimer();
// Cleanup
return () => {
activityEvents.forEach(event => {
document.removeEventListener(event, handleActivity);
});
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
if (throttleTimeout) clearTimeout(throttleTimeout);
};
}, [user, resetActivityTimer]);
// Don't render if user is not logged in
if (!user) return null;
return (
<Dialog open={showWarning} onOpenChange={(open) => {
if (!open) {
// Prevent closing dialog by clicking outside
// User must click a button
return;
}
}}>
<DialogContent
className="max-w-md"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="bg-[#ff9e77]/10 p-3 rounded-full">
<AlertTriangle className="h-6 w-6 text-[#ff9e77]" />
</div>
<DialogTitle className="text-[#422268]">
Session About to Expire
</DialogTitle>
</div>
<DialogDescription className="text-[#664fa3]">
Your session will expire in <strong className="text-[#422268] text-lg">{timeRemaining}</strong> seconds due to inactivity.
<div className="mt-4 p-4 bg-[#f1eef9] rounded-lg border border-[#ddd8eb]">
<p className="text-sm text-[#422268]">
Click <strong>"Stay Logged In"</strong> to continue your session, or you will be automatically logged out.
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col sm:flex-row gap-3 mt-4">
<Button
variant="outline"
onClick={handleSessionExpired}
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9]"
>
Log Out Now
</Button>
<Button
onClick={handleExtendSession}
disabled={isExtending}
className="bg-[#664fa3] hover:bg-[#422268] text-white"
>
{isExtending ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Extending...
</>
) : (
'Stay Logged In'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default IdleSessionWarning;

View File

@@ -138,7 +138,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[800px] rounded-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[800px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Upload className="h-6 w-6" />

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import api from '../utils/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { toast } from 'sonner';
import { Loader2, Mail, Copy, Check } from 'lucide-react';
const InviteMemberDialog = ({ open, onOpenChange, onSuccess }) => {
const [formData, setFormData] = useState({
email: '',
first_name: '',
last_name: '',
phone: '',
role: 'admin'
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const [invitationUrl, setInvitationUrl] = useState(null);
const [copied, setCopied] = useState(false);
const [roles, setRoles] = useState([]);
const [loadingRoles, setLoadingRoles] = useState(false);
// Fetch roles when dialog opens
useEffect(() => {
if (open) {
fetchRoles();
}
}, [open]);
const fetchRoles = async () => {
setLoadingRoles(true);
try {
// New endpoint returns roles based on user's permission level
// Superadmin: all roles
// Admin: admin, finance, and non-elevated custom roles
const response = await api.get('/admin/roles/assignable');
setRoles(response.data);
} catch (error) {
console.error('Failed to fetch assignable roles:', error);
toast.error('Failed to load roles. Please try again.');
} finally {
setLoadingRoles(false);
}
};
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) {
return;
}
setLoading(true);
try {
const response = await api.post('/admin/users/invite', formData);
toast.success('Invitation sent successfully');
// Show invitation URL
setInvitationUrl(response.data.invitation_url);
// Don't close dialog yet - show invitation URL first
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to send invitation';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(invitationUrl);
setCopied(true);
toast.success('Invitation link copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
const handleClose = () => {
// Reset form
setFormData({
email: '',
first_name: '',
last_name: '',
phone: '',
role: 'admin'
});
setInvitationUrl(null);
setCopied(false);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6" />
{invitationUrl ? 'Invitation Sent' : 'Invite Member'}
</DialogTitle>
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{invitationUrl
? 'The invitation has been sent via email. You can also copy the link below.'
: 'Send an email invitation to join as member. They will set their own password.'}
</DialogDescription>
</DialogHeader>
{invitationUrl ? (
// Show invitation URL after successful send
<div className="py-4">
<Label className="text-[var(--purple-ink)] mb-2 block">Invitation Link (expires in 7 days)</Label>
<div className="flex gap-2">
<Input
value={invitationUrl}
readOnly
className="rounded-xl border-2 border-[var(--neutral-800)] bg-gray-50"
/>
<Button
onClick={copyToClipboard}
className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white flex-shrink-0"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy
</>
)}
</Button>
</div>
</div>
) : (
// Show invitation form
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email */}
<div className="grid gap-2">
<Label htmlFor="email" className="text-[var(--purple-ink)]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="member@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* First Name (Optional) */}
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
First Name <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Jane"
/>
</div>
{/* Last Name (Optional) */}
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
Last Name <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Doe"
/>
</div>
{/* Phone (Optional) */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
Phone <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="(555) 123-4567"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Send Invitation
</>
)}
</Button>
</DialogFooter>
</form>
)}
{invitationUrl && (
<DialogFooter>
<Button
onClick={handleClose}
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
>
Done
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
};
export default InviteMemberDialog;

View File

@@ -123,7 +123,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] rounded-2xl">
<DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader>
<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="John"
placeholder="Jane"
/>
</div>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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 {
@@ -12,11 +13,12 @@ import {
const Navbar = () => {
const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// LOAF logo (local)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
// Get logo URL from theme config (with fallback to default)
const loafLogo = getLogoUrl();
const handleLogout = () => {
logout();
@@ -39,7 +41,7 @@ const Navbar = () => {
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="admin-nav-button"
>
Admin Panel
Dashboard
</button>
</Link>
)}
@@ -110,7 +112,7 @@ const Navbar = () => {
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Dashboard
My Profile
</Link>
<Link
to="/events"
@@ -170,14 +172,7 @@ const Navbar = () => {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
to="/profile"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="profile-nav-button"
>
Profile
</Link>
</nav>
{/* Mobile Hamburger Button */}
@@ -231,7 +226,7 @@ const Navbar = () => {
)}
{/* Navigation Links */}
<nav className="flex-1 overflow-y-auto py-6 px-4">
<nav className="flex-1 overflow-y-auto scrollbar-dashboard py-6 px-4">
<div className="space-y-2">
<Link
to="/"
@@ -373,7 +368,7 @@ const Navbar = () => {
className="w-full bg-background/20 hover:bg-background/30 text-white rounded-lg"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Admin Panel
Dashboard
</Button>
</Link>
)}

View File

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

View File

@@ -159,7 +159,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{plan ? 'Edit Plan' : 'Create New Plan'}

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Button } from './ui/button';
import { useThemeConfig } from '../context/ThemeConfigContext';
const PublicFooter = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return (
<>
@@ -60,7 +62,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" }}>
© 2025 LOAF. All Rights Reserved.
© {new Date().getFullYear()} 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,6 +2,7 @@ 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,
@@ -12,6 +13,7 @@ import {
const PublicNavbar = () => {
const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate();
const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -30,8 +32,8 @@ const PublicNavbar = () => {
return location.pathname.startsWith('/about');
};
// LOAF logo (local)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
// Get logo URL from theme config (with fallback to default)
const loafLogo = getLogoUrl();
const handleAuthAction = () => {
if (user) {
@@ -75,8 +77,24 @@ 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'>
<div className='flex gap-4 sm:gap-6 items-center'>
{user && (
<span
className="text-white text-base font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Welcome, {user.first_name}
</span>
)}
{(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link
to="/admin"
className="text-white text-base font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Dashboard
</Link>
)}
<button
onClick={handleAuthAction}
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
@@ -165,7 +183,7 @@ const PublicNavbar = () => {
className={getDesktopLinkClasses(user ? "/dashboard" : "/become-a-member")}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{user ? 'Dashboard' : 'Become a Member'}
{user ? 'My Profile' : 'Become a Member'}
</Link>
{!user && (
<Link
@@ -176,7 +194,71 @@ const PublicNavbar = () => {
Members Only
</Link>
)}
<Link
{user && (
<>
<Link
to="/events"
className={getDesktopLinkClasses('/events')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Events
</Link>
<Link
to="/members/calendar"
className={getDesktopLinkClasses('/members/calendar')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Calendar
</Link>
<Link
to="/members/directory"
className={getDesktopLinkClasses('/members/directory')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Directory
</Link>
<Link
to="/members/gallery"
className={getDesktopLinkClasses('/members/gallery')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Gallery
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`${location.pathname.startsWith('/members/newsletters') || location.pathname.startsWith('/members/financials') || location.pathname.startsWith('/members/bylaws')
? "text-[var(--orange-light)] hover:text-[var(--orange-coral)]"
: "text-white hover:opacity-80"} text-[17.5px] font-medium transition-all flex items-center gap-1 bg-transparent border-none cursor-pointer px-3 py-1 rounded-md`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Documents
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
<DropdownMenuItem asChild>
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Newsletters
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/members/financials" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Financials
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/members/bylaws" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Bylaws
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{/* <Link
to="/resources"
className={getDesktopLinkClasses('/resources')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
@@ -189,7 +271,7 @@ const PublicNavbar = () => {
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Contact Us
</Link>
</Link> */}
</nav>
</header>
@@ -204,7 +286,7 @@ const PublicNavbar = () => {
/>
{/* Drawer */}
<div className="fixed right-0 top-0 h-full w-[280px] bg-brand-purple shadow-xl overflow-y-auto">
<div className="fixed right-0 top-0 h-full w-[280px] bg-brand-purple shadow-xl overflow-y-auto scrollbar-dashboard">
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-[var(--purple-deep)]">
<span className="text-white text-lg font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -219,6 +301,18 @@ 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
@@ -270,7 +364,7 @@ const PublicNavbar = () => {
className={getMobileLinkClasses(user ? "/dashboard" : "/become-a-member")}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{user ? 'Dashboard' : 'Become a Member'}
{user ? 'My Profile' : 'Become a Member'}
</Link>
{!user && (
@@ -284,6 +378,80 @@ 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)}
@@ -304,6 +472,16 @@ 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

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

View File

@@ -0,0 +1,66 @@
import React from "react";
import { Card } from "./ui/card";
export const StatCard = ({
title,
value,
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>
</div>
<p
className="text-sm text-brand-purple "
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{title}
</p>
</Card>
);
};

View File

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

@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { Card } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Receipt, CreditCard, Heart, Calendar, ExternalLink, DollarSign } from 'lucide-react';
/**
* TransactionHistory Component
* Displays user transaction history including subscriptions and donations
*
* @param {Object} props
* @param {Array} props.subscriptions - List of subscription transactions
* @param {Array} props.donations - List of donation transactions
* @param {number} props.totalSubscriptionCents - Total subscription amount in cents
* @param {number} props.totalDonationCents - Total donation amount in cents
* @param {boolean} props.loading - Loading state
* @param {boolean} props.isAdmin - Whether viewing as admin (shows extra fields)
*/
const TransactionHistory = ({
subscriptions = [],
donations = [],
totalSubscriptionCents = 0,
totalDonationCents = 0,
loading = false,
isAdmin = false
}) => {
const [activeTab, setActiveTab] = useState('all');
const formatAmount = (cents) => {
if (!cents) return '$0.00';
return `$${(cents / 100).toFixed(2)}`;
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getStatusBadgeClass = (status) => {
switch (status?.toLowerCase()) {
case 'active':
case 'completed':
return 'bg-green-100 text-green-800 border-green-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'cancelled':
case 'failed':
case 'expired':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const allTransactions = [
...subscriptions.map(s => ({ ...s, sortDate: s.created_at })),
...donations.map(d => ({ ...d, sortDate: d.created_at }))
].sort((a, b) => new Date(b.sortDate) - new Date(a.sortDate));
const TransactionRow = ({ transaction }) => {
const isSubscription = transaction.type === 'subscription';
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-[var(--neutral-800)] last:border-b-0 hover:bg-[var(--lavender-500)] transition-colors">
<div className="flex items-start gap-3 mb-2 sm:mb-0">
<div className={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
{isSubscription ? (
<CreditCard className="h-5 w-5 text-white" />
) : (
<Heart className="h-5 w-5 text-white" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{transaction.description}
</span>
<Badge className={`text-xs ${getStatusBadgeClass(transaction.status)}`}>
{transaction.status}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-brand-purple mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Calendar className="h-3 w-3" />
<span>{formatDate(transaction.payment_completed_at || transaction.created_at)}</span>
{transaction.card_brand && transaction.card_last4 && (
<>
<span className="text-[var(--neutral-800)]"></span>
<span>{transaction.card_brand} ****{transaction.card_last4}</span>
</>
)}
{isSubscription && transaction.billing_cycle && (
<>
<span className="text-[var(--neutral-800)]"></span>
<span className="capitalize">{transaction.billing_cycle}</span>
</>
)}
</div>
{isAdmin && transaction.manual_payment && (
<div className="text-xs text-[var(--orange-light)] mt-1">
Manual Payment {transaction.manual_payment_notes && `- ${transaction.manual_payment_notes}`}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 pl-10 sm:pl-0">
<div className="text-right">
<div className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatAmount(transaction.amount_cents)}
</div>
{isSubscription && transaction.donation_cents > 0 && (
<div className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
(incl. {formatAmount(transaction.donation_cents)} donation)
</div>
)}
</div>
{transaction.stripe_receipt_url && (
<a
href={transaction.stripe_receipt_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-brand-purple hover:text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg transition-colors"
title="View Receipt"
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
</div>
);
};
const EmptyState = ({ type }) => (
<div className="py-12 text-center">
<div className="mx-auto w-16 h-16 bg-[var(--lavender-300)] rounded-full flex items-center justify-center mb-4">
{type === 'subscription' ? (
<CreditCard className="h-8 w-8 text-brand-purple" />
) : type === 'donation' ? (
<Heart className="h-8 w-8 text-brand-purple" />
) : (
<Receipt className="h-8 w-8 text-brand-purple" />
)}
</div>
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{type === 'subscription'
? 'No subscription payments yet'
: type === 'donation'
? 'No donations yet'
: 'No transactions yet'}
</p>
</div>
);
if (loading) {
return (
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--purple-lavender)]"></div>
</div>
</Card>
);
}
return (
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Receipt className="h-6 w-6 text-brand-purple" />
Transaction History
</h2>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<CreditCard className="h-4 w-4" />
<span className="text-sm">Total Subscriptions</span>
</div>
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatAmount(totalSubscriptionCents)}
</div>
<div className="text-xs text-brand-purple mt-1">{subscriptions.length} payment(s)</div>
</div>
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Heart className="h-4 w-4" />
<span className="text-sm">Total Donations</span>
</div>
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatAmount(totalDonationCents)}
</div>
<div className="text-xs text-brand-purple mt-1">{donations.length} donation(s)</div>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 mb-4">
<TabsTrigger value="all" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
All ({allTransactions.length})
</TabsTrigger>
<TabsTrigger value="subscriptions" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
Subscriptions ({subscriptions.length})
</TabsTrigger>
<TabsTrigger value="donations" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
Donations ({donations.length})
</TabsTrigger>
</TabsList>
<div className="border border-[var(--neutral-800)] rounded-xl overflow-hidden">
<TabsContent value="all" className="m-0">
{allTransactions.length > 0 ? (
allTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))
) : (
<EmptyState type="all" />
)}
</TabsContent>
<TabsContent value="subscriptions" className="m-0">
{subscriptions.length > 0 ? (
subscriptions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))
) : (
<EmptyState type="subscription" />
)}
</TabsContent>
<TabsContent value="donations" className="m-0">
{donations.length > 0 ? (
donations.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))
) : (
<EmptyState type="donation" />
)}
</TabsContent>
</div>
</Tabs>
</Card>
);
};
export default TransactionHistory;

View File

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

@@ -896,7 +896,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]">
WordPress Import Wizard

View File

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

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

View File

@@ -1,50 +1,65 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -50,7 +50,7 @@ CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
className={cn("max-h-[300px] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden", className)}
{...props} />
))

View File

@@ -47,7 +47,7 @@ const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props} />

View File

@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, .
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-brand-light-lavender data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
@@ -50,7 +50,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
@@ -63,7 +63,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref)
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-brand-light-lavender focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}

View File

@@ -7,7 +7,7 @@ const Input = React.forwardRef(({ className, type, ...props }, ref) => {
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}

View File

@@ -52,7 +52,7 @@ const SelectContent = React.forwardRef(({ className, children, position = "poppe
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden scrollbar-dashboard scrollbar-x-dashboard rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -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",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:text-white focus:text-white",
className
)}
{...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>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemText className="">{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName

View File

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

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

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

View File

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

View File

@@ -1,12 +1,14 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
import api from '../utils/api';
import logger from '../utils/logger';
const AuthContext = createContext();
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
// Log environment on module load for debugging
console.log('[AuthContext] Module initialized with:', {
logger.log('[AuthContext] Module initialized with:', {
REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
API_URL: API_URL
@@ -55,31 +57,31 @@ export const AuthProvider = ({ children }) => {
});
setPermissions(response.data.permissions || []);
} catch (error) {
console.error('Failed to fetch permissions:', error);
logger.error('Failed to fetch permissions:', error);
setPermissions([]);
}
};
const login = async (email, password) => {
try {
console.log('[AuthContext] Starting login request...', {
logger.log('[AuthContext] Starting login request...', {
API_URL: API_URL,
envBackendUrl: process.env.REACT_APP_BACKEND_URL,
fullUrl: `${API_URL}/api/auth/login`
});
const response = await axios.post(
`${API_URL}/api/auth/login`,
// Use api instance for retry logic
const response = await api.post(
'/auth/login',
{ email, password },
{
timeout: 30000, // 30 second timeout
headers: {
'Content-Type': 'application/json'
}
}
);
console.log('[AuthContext] Login response received:', {
logger.log('[AuthContext] Login response received:', {
status: response.status,
hasToken: !!response.data?.access_token,
hasUser: !!response.data?.user
@@ -87,39 +89,46 @@ export const AuthProvider = ({ children }) => {
const { access_token, user: userData } = response.data;
// Store token first
localStorage.setItem('token', access_token);
console.log('[AuthContext] Token stored in localStorage');
if (!access_token || !userData) {
throw new Error('Invalid response from server - missing token or user data');
}
// Update state
// Store token FIRST and verify it was stored
localStorage.setItem('token', access_token);
const storedToken = localStorage.getItem('token');
if (storedToken !== access_token) {
throw new Error('Failed to store token in localStorage');
}
logger.log('[AuthContext] Token stored and verified in localStorage');
// Update state in correct order
setToken(access_token);
setUser(userData);
console.log('[AuthContext] User state updated:', {
logger.log('[AuthContext] User state updated:', {
email: userData.email,
role: userData.role
});
// Fetch user permissions (don't let this fail the login)
// Use setTimeout to defer permission fetching slightly
setTimeout(async () => {
try {
console.log('[AuthContext] Fetching permissions...');
await fetchPermissions(access_token);
console.log('[AuthContext] Permissions fetched successfully');
} catch (error) {
console.error('[AuthContext] Failed to fetch permissions (non-critical):', {
message: error.message,
response: error.response?.data,
status: error.response?.status
});
// Don't throw - permissions can be fetched later if needed
}
}, 100); // Small delay to ensure state is settled
// Fetch permissions immediately and WAIT for it (but don't fail login if it fails)
try {
logger.log('[AuthContext] Fetching permissions...');
await fetchPermissions(access_token);
logger.log('[AuthContext] Permissions fetched successfully');
} catch (permError) {
logger.error('[AuthContext] Failed to fetch permissions (non-critical):', {
message: permError.message,
response: permError.response?.data,
status: permError.response?.status
});
// Set empty permissions array so hasPermission doesn't break
setPermissions([]);
// Don't throw - login succeeded even if permissions failed
}
return userData;
} catch (error) {
// Enhanced error logging
console.error('[AuthContext] Login failed:', {
logger.error('[AuthContext] Login failed:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
@@ -131,6 +140,12 @@ export const AuthProvider = ({ children }) => {
}
});
// Clear any partial state
localStorage.removeItem('token');
setToken(null);
setUser(null);
setPermissions([]);
// Re-throw to let Login component handle the error
throw error;
}
@@ -160,7 +175,7 @@ export const AuthProvider = ({ children }) => {
setUser(response.data);
return response.data;
} catch (error) {
console.error('Failed to refresh user:', error);
logger.error('Failed to refresh user:', error);
// If token expired, logout
if (error.response?.status === 401) {
logout();

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -1,6 +1,7 @@
import React from 'react';
import 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';
@@ -16,7 +17,9 @@ root.render(
enableSystem={false}
storageKey="admin-theme"
>
<App />
<ThemeConfigProvider>
<App />
</ThemeConfigProvider>
</ThemeProvider>
</React.StrictMode>
);

View File

@@ -1,6 +1,8 @@
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);
@@ -46,29 +48,48 @@ const AdminLayout = ({ children }) => {
};
return (
<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}
<UsersProvider>
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
{/* Sidebar */}
<AdminSidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
isMobile={isMobile}
/>
)}
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
{/* 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>
);
};

View File

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

View File

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

View File

@@ -7,8 +7,11 @@ import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale } from 'lucide-react';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
import { toast } from 'sonner';
import TransactionHistory from '../components/TransactionHistory';
import MemberBadge from '@/components/MemberBadge';
import useMemberTiers from '../hooks/use-member-tiers'
const Dashboard = () => {
const { user, resendVerificationEmail, refreshUser } = useAuth();
@@ -17,10 +20,16 @@ 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 () => {
@@ -46,6 +55,19 @@ const Dashboard = () => {
}
};
const fetchTransactions = async () => {
try {
setTransactionsLoading(true);
const response = await api.get('/members/transactions');
setTransactions(response.data);
} catch (error) {
console.error('Failed to load transactions:', error);
// Don't show error toast - transactions are optional
} finally {
setTransactionsLoading(false);
}
};
const handleResendVerification = async () => {
setResendLoading(true);
try {
@@ -70,6 +92,7 @@ const Dashboard = () => {
}
};
const getStatusBadge = (status) => {
const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
@@ -110,6 +133,8 @@ const Dashboard = () => {
return messages[status] || '';
};
return (
<div className="min-h-screen bg-background">
<Navbar />
@@ -168,70 +193,92 @@ const Dashboard = () => {
</div>
<Link to="/profile">
<Button
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-6"
className="btn-lavender"
data-testid="view-profile-button"
>
<User className="h-4 w-4 mr-2" />
View Profile
Edit Profile
</Button>
</Link>
</div>
</Card>
{/* Grid Layout */}
<div className="grid lg:grid-cols-3 gap-8">
<div className="grid lg:grid-cols-2 gap-8">
{/* Quick Stats */}
<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" }}>
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
</p>
</div>
{user?.subscription_start_date && user?.subscription_end_date && (
<>
<div className="pt-4 border-t border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
</p>
</div>
<div 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" }}>Days Remaining</p>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
{joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
</p>
</div>
</>
)}
</div>
</Card>
{!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>
{/* Upcoming Events */}
<Card className="lg:col-span-2 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
<Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Upcoming Events
My Event Activity
</h3>
<Link to="/events">
<Button
variant="ghost"
className="text-[var(--orange-light)] hover:text-brand-purple "
data-testid="view-all-events-button"
>
View All
<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>
@@ -313,155 +360,18 @@ const Dashboard = () => {
</Card>
)}
{/* Event Activity Section */}
<div className="mt-12">
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
My Event Activity
</h2>
</div>
{activityLoading ? (
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event activity...</p>
) : eventActivity ? (
<div className="space-y-8">
{/* Stats Cards */}
<div className="grid md:grid-cols-2 gap-6">
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-4">
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-lg">
<Calendar className="h-8 w-8 text-brand-purple " />
</div>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{eventActivity.total_rsvps}
</p>
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-4">
<div className="bg-[var(--green-light)]/20 p-4 rounded-lg">
<CheckCircle className="h-8 w-8 text-[var(--green-light)]" />
</div>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Events Attended</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{eventActivity.total_attended}
</p>
</div>
</div>
</Card>
</div>
{/* Upcoming RSVP'd Events */}
{eventActivity.upcoming_events && eventActivity.upcoming_events.length > 0 && (
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Upcoming Events (RSVP'd)
</h3>
<div className="space-y-3">
{eventActivity.upcoming_events.map((event) => (
<Link to={`/events/${event.id}`} key={event.id}>
<div className="p-4 border border-[var(--neutral-800)] rounded-xl hover:border-brand-purple hover:shadow-md transition-all">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
</div>
<Badge className={
event.rsvp_status === 'yes' ? 'bg-[var(--green-light)] text-white' :
event.rsvp_status === 'maybe' ? 'bg-orange-100 text-orange-700' :
'bg-gray-200 text-gray-700'
}>
{event.rsvp_status === 'yes' ? 'Going' :
event.rsvp_status === 'maybe' ? 'Maybe' : 'Not Going'}
</Badge>
</div>
</div>
</Link>
))}
</div>
</Card>
)}
{/* Past Events & Attendance */}
{eventActivity.past_events && eventActivity.past_events.length > 0 && (
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Past Events
</h3>
<div className="space-y-3">
{eventActivity.past_events.slice(0, 5).map((event) => (
<div key={event.id} className="p-4 border border-[var(--neutral-800)] rounded-xl">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<Badge className={event.attended ? 'bg-[var(--green-light)] text-white' : 'bg-gray-200 text-gray-700'}>
{event.attended ? 'Attended' : 'Did not attend'}
</Badge>
{event.attended && event.attended_at && (
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Checked in: {new Date(event.attended_at).toLocaleDateString()}
</p>
)}
</div>
</div>
</div>
))}
</div>
{eventActivity.past_events.length > 5 && (
<p className="text-sm text-center text-brand-purple mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing 5 of {eventActivity.past_events.length} past events
</p>
)}
</Card>
)}
{/* No Events Message */}
{(!eventActivity.upcoming_events || eventActivity.upcoming_events.length === 0) &&
(!eventActivity.past_events || eventActivity.past_events.length === 0) && (
<Card className="p-12 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="text-center">
<Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Event Activity Yet
</h3>
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Browse upcoming events and RSVP to start building your event history!
</p>
<Link to="/events">
<Button className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full 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>
)}
{/* Transaction History Section */}
<div className="mt-8">
<TransactionHistory
subscriptions={transactions.subscriptions}
donations={transactions.donations}
totalSubscriptionCents={transactions.total_subscription_amount_cents}
totalDonationCents={transactions.total_donation_amount_cents}
loading={transactionsLoading}
isAdmin={false}
/>
</div>
</div>
<MemberFooter />
</div>

View File

@@ -155,7 +155,7 @@ const EventDetails = () => {
disabled={rsvpLoading}
className={`rounded-full px-8 py-6 flex items-center gap-2 ${event.user_rsvp_status === 'yes'
? 'bg-[var(--green-light)] text-white'
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background'
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-lavender'
}`}
data-testid="rsvp-yes-button"
>

View File

@@ -2,9 +2,11 @@ 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 loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return (
<div className="min-h-screen bg-background">

File diff suppressed because it is too large Load Diff

View File

@@ -258,7 +258,7 @@ const Register = () => {
<Button
type="button"
onClick={handleNext}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform"
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform"
>
Next
<ArrowRight className="ml-2 h-5 w-5" />
@@ -267,7 +267,7 @@ const Register = () => {
<Button
type="submit"
disabled={loading}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-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 ? 'Creating Account...' : 'Create Account'}

View File

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

View File

@@ -4,13 +4,16 @@ import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle, Globe } from 'lucide-react';
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle, Globe, CircleMinus } from 'lucide-react';
import { StatCard } from '../../components/StatCard';
const AdminDashboard = () => {
const [stats, setStats] = useState({
totalMembers: 0,
pendingValidations: 0,
activeMembers: 0
activeMembers: 0,
inactiveMembers: 0
});
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
const [loading, setLoading] = useState(true);
@@ -29,7 +32,8 @@ const AdminDashboard = () => {
pendingValidations: users.filter(u =>
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
).length,
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length,
inactiveMembers: users.filter(u => u.status === 'inactive' && u.role === 'member').length
});
// Find users who have received 3+ reminders (may need personal outreach)
@@ -56,7 +60,7 @@ const AdminDashboard = () => {
return (
<>
<div className='flex justify-between items-center'>
<div className='flex flex-col md:flex-row md:justify-between md:items-center'>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Admin Dashboard
@@ -65,9 +69,9 @@ const AdminDashboard = () => {
Manage users, events, and membership applications.
</p>
</div>
<Link to={'/'}>
<Link to={'/'} className=''>
<Button
className="btn-lavender"
className="btn-lavender mb-8 md:mb-0 "
>
<Globe />
View Public Site
@@ -76,52 +80,42 @@ const AdminDashboard = () => {
</div>
{/* Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-total-users">
<div className='flex items-start justify-between'>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.totalMembers}
</p>
<div className="flex items-start justify-between mb-4">
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-brand-purple" />
</div>
</div>
</div>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
</Card>
<div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
Quick Overview
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 ">
<StatCard
title="Total Members"
value={loading ? '-' : stats.totalMembers}
icon={Users}
iconBgClass="text-brand-purple"
dataTestId="stat-total-users"
/>
<StatCard
title="Pending Validations"
value={loading ? '-' : stats.pendingValidations}
icon={Clock}
iconBgClass="text-brand-light-orange"
dataTestId="stat-total-users"
/>
<StatCard
title="Active Members"
value={loading ? '-' : stats.activeMembers}
icon={CheckCircle}
iconBgClass="text-[var(--green-light)]"
dataTestId="stat-total-users"
/>
<StatCard
title="Inactive Members"
value={loading ? '-' : stats.inactiveMembers}
icon={CircleMinus}
iconBgClass="text-brand-pink"
dataTestId="stat-total-users"
/>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-pending-validations">
<div className='flex items-start justify-between'>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.pendingValidations}
</p>
<div className="flex items-start justify-between mb-4">
<div className=" p-3 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" />
</div>
</div>
</div>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-active-members">
<div className='flex items-start justify-between'>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.activeMembers}
</p>
<div className="flex items-start justify-between mb-4">
<div className="bg-[var(--green-light)]/20 p-3 rounded-lg">
<CheckCircle className="h-6 w-6 text-[var(--green-light)]" />
</div>
</div>
</div>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
</Card>
</div>
</div>
{/* Quick Actions */}

View File

@@ -3,6 +3,7 @@ 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,
@@ -17,6 +18,14 @@ 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 {
@@ -28,7 +37,13 @@ import {
Loader2,
Download,
FileDown,
Calendar
Calendar,
ChevronDown,
ChevronUp,
ExternalLink,
Copy,
CreditCard,
Info
} from 'lucide-react';
const AdminDonations = () => {
@@ -43,6 +58,7 @@ const AdminDonations = () => {
const [statusFilter, setStatusFilter] = useState('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [expandedRows, setExpandedRows] = useState(new Set());
useEffect(() => {
fetchData();
@@ -157,15 +173,29 @@ const AdminDonations = () => {
});
};
const getStatusBadgeVariant = (status) => {
const variants = {
completed: 'default',
pending: 'secondary',
failed: 'destructive'
};
return variants[status] || 'outline';
const toggleRowExpansion = (donationId) => {
setExpandedRows((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(donationId)) {
newExpanded.delete(donationId);
} else {
newExpanded.add(donationId);
}
return newExpanded;
});
};
const copyToClipboard = async (text, label) => {
try {
await navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
} catch (error) {
toast.error('Failed to copy to clipboard');
}
};
/*
*/
const getTypeBadgeColor = (type) => {
return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-brand-purple ';
};
@@ -364,90 +394,207 @@ const AdminDonations = () => {
{/* Donations Table */}
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-[var(--lavender-300)] border-b-2 border-[var(--neutral-800)]">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Donor
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Type
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Amount
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Status
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Date
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Method
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--neutral-800)]">
<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>
{filteredDonations.length === 0 ? (
<tr>
<td colSpan="6" className="px-6 py-12 text-center">
<TableRow>
<TableCell colSpan={7} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<Heart className="h-12 w-12 text-[var(--neutral-800)]" />
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donations.length === 0 ? 'No donations yet' : 'No donations match your filters'}
</p>
</div>
</td>
</tr>
</TableCell>
</TableRow>
) : (
filteredDonations.map((donation) => (
<tr key={donation.id} className="hover:bg-[var(--lavender-400)] transition-colors">
<td className="px-6 py-4">
<div>
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.donor_name || 'Anonymous'}
</p>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donation.donor_email || 'No email'}
</p>
</div>
</td>
<td className="px-6 py-4">
<Badge
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none rounded-full px-3 py-1`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
{donation.donation_type === 'member' ? 'Member' : 'Public'}
</Badge>
</td>
<td className="px-6 py-4">
<p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.amount}
</p>
</td>
<td className="px-6 py-4">
<Badge variant={getStatusBadgeVariant(donation.status)} className="rounded-full">
{donation.status.charAt(0).toUpperCase() + donation.status.slice(1)}
</Badge>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-brand-purple ">
<Calendar className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(donation.created_at)}
</span>
</div>
</td>
<td className="px-6 py-4">
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donation.payment_method || 'N/A'}
</p>
</td>
</tr>
))
filteredDonations.map((donation) => {
const isExpanded = expandedRows.has(donation.id);
return (
<React.Fragment key={donation.id}>
<TableRow>
<TableCell>
<div>
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.donor_name || 'Anonymous'}
</p>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donation.donor_email || 'No email'}
</p>
</div>
</TableCell>
<TableCell>
<Badge
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none px-3 py-1`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
{donation.donation_type === 'member' ? 'Member' : 'Public'}
</Badge>
</TableCell>
<TableCell>
<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>
<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" }}>
{donation.payment_method || 'N/A'}
</p>
</TableCell>
<TableCell className="text-center">
<Button
size="sm"
variant="ghost"
onClick={() => toggleRowExpansion(donation.id)}
className="text-brand-purple hover:bg-[var(--neutral-800)]"
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="bg-[var(--lavender-400)]/30">
<TableCell colSpan={7} className="p-6">
<div className="space-y-4">
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Transaction Details
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Payment Information */}
<div className="space-y-3">
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<CreditCard className="h-4 w-4" />
Payment Information
</h5>
<div className="space-y-2 text-sm">
{donation.payment_completed_at && (
<div className="flex justify-between">
<span className="text-brand-purple ">Payment Date:</span>
<span className="text-[var(--purple-ink)] font-medium">{formatDate(donation.payment_completed_at)}</span>
</div>
)}
{donation.payment_method && (
<div className="flex justify-between">
<span className="text-brand-purple ">Payment Method:</span>
<span className="text-[var(--purple-ink)] font-medium capitalize">{donation.payment_method}</span>
</div>
)}
{donation.card_brand && donation.card_last4 && (
<div className="flex justify-between">
<span className="text-brand-purple ">Card:</span>
<span className="text-[var(--purple-ink)] font-medium">{donation.card_brand} ****{donation.card_last4}</span>
</div>
)}
</div>
</div>
{/* Stripe Transaction IDs */}
<div className="space-y-3">
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Info className="h-4 w-4" />
Stripe Transaction IDs
</h5>
<div className="space-y-2 text-sm">
{donation.stripe_payment_intent_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Payment Intent:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{donation.stripe_payment_intent_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(donation.stripe_payment_intent_id, 'Payment Intent ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{donation.stripe_charge_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Charge ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{donation.stripe_charge_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(donation.stripe_charge_id, 'Charge ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{donation.stripe_customer_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Customer ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{donation.stripe_customer_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(donation.stripe_customer_id, 'Customer ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{donation.stripe_receipt_url && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Receipt:</span>
<Button
size="sm"
variant="outline"
onClick={() => window.open(donation.stripe_receipt_url, '_blank')}
className="text-brand-purple hover:bg-[var(--neutral-800)]"
>
<ExternalLink className="h-3 w-3 mr-1" />
View Receipt
</Button>
</div>
)}
</div>
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
</Card>

View File

@@ -254,7 +254,7 @@ const AdminEventAttendance = () => {
</div>
<Button
onClick={exportToCSV}
className="bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] text-white rounded-xl"
className="bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] dark:bg-[var(--green-forest)] text-white rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Download className="h-4 w-4 mr-2" />
@@ -282,7 +282,7 @@ const AdminEventAttendance = () => {
)}
</div>
</div>
<Badge className={`${event.published ? 'bg-[var(--green-light)]' : 'bg-[var(--neutral-800)]'} text-white px-3 py-1`}>
<Badge className={`${event.published ? 'bg-[var(--green-light)] dark:bg-[var(--green-forest)]' : 'bg-[var(--neutral-800)]'} text-white px-3 py-1`}>
{event.published ? 'Published' : 'Draft'}
</Badge>
</div>
@@ -302,7 +302,7 @@ const AdminEventAttendance = () => {
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center gap-3">
<UserCheck className="h-8 w-8 text-[var(--green-light)]" />
<UserCheck className="h-8 w-8 text-[var(--green-light)] dark:text-[var(--green-forest)]" />
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
@@ -320,7 +320,7 @@ const AdminEventAttendance = () => {
</div>
</Card>
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center gap-3">
<HelpCircle className="h-8 w-8 text-[var(--gold-warm)]" />
<div>
@@ -350,7 +350,7 @@ const AdminEventAttendance = () => {
onClick={() => setActiveTab('all')}
variant={activeTab === 'all' ? 'default' : 'outline'}
className={`rounded-xl ${activeTab === 'all'
? 'bg-brand-purple hover:bg-[var(--purple-ink)] text-white'
? 'bg-brand-purple hover:bg-[var(--purple-ink)] dark:bg-brand-dark-lavender text-white'
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
@@ -361,7 +361,7 @@ const AdminEventAttendance = () => {
onClick={() => setActiveTab('yes')}
variant={activeTab === 'yes' ? 'default' : 'outline'}
className={`rounded-xl ${activeTab === 'yes'
? 'bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] text-white'
? 'bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] dark:bg-[var(--green-forest)] text-white'
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
@@ -383,7 +383,7 @@ const AdminEventAttendance = () => {
onClick={() => setActiveTab('maybe')}
variant={activeTab === 'maybe' ? 'default' : 'outline'}
className={`rounded-xl ${activeTab === 'maybe'
? 'bg-[var(--gold-warm)] hover:bg-[var(--gold-soft)] text-[var(--purple-ink)]'
? 'bg-[var(--gold-warm)] dark:bg-orange-400 hover:bg-[var(--gold-soft)] text-white'
: 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
@@ -483,7 +483,7 @@ const AdminEventAttendance = () => {
<td className="px-4 py-3">
<Badge
className={`${rsvp.rsvp_status === 'yes'
? 'bg-[var(--green-light)]'
? 'bg-[var(--green-light)] dark:bg-[var(--green-forest)]'
: rsvp.rsvp_status === 'no'
? 'bg-[var(--orange-soft)]'
: 'bg-[var(--gold-warm)] text-[var(--purple-ink)]'
@@ -498,7 +498,7 @@ const AdminEventAttendance = () => {
onClick={() => handleIndividualAttendance(rsvp.user_id, false)}
disabled={saving}
size="sm"
className="bg-[var(--green-light)] hover:bg-[var(--green-eucalyptus)] text-white rounded-lg min-w-[120px]"
className="bg-[var(--green-light)] dark:bg-[var(--green-forest)] hover:bg-[var(--green-eucalyptus)] text-white rounded-lg min-w-[120px]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Check className="h-3 w-3 mr-1" />
@@ -510,7 +510,7 @@ const AdminEventAttendance = () => {
disabled={saving}
size="sm"
variant="outline"
className="border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--green-light)] hover:text-white hover:border-[var(--green-light)] rounded-lg min-w-[120px]"
className="border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--green-light)] dark:bg-[var(--green-forest)] hover:text-white hover:border-[var(--green-light)] dark:bg-[var(--green-forest)] rounded-lg min-w-[120px]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<X className="h-3 w-3 mr-1" />

View File

@@ -158,7 +158,7 @@ const AdminEvents = () => {
</Button>
</DialogTrigger>
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{editingEvent ? 'Edit Event' : 'Create New Event'}
@@ -186,7 +186,7 @@ const AdminEvents = () => {
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
className="w-full border-2 border-[var(--neutral-800)] focus:border-brand-purple rounded-lg p-3"
className="w-full border-2 border-[var(--neutral-800)] bg-background focus:border-brand-purple rounded-lg p-3"
/>
</div>
@@ -249,7 +249,7 @@ const AdminEvents = () => {
id="published"
checked={formData.published}
onChange={(e) => setFormData({ ...formData, published: e.target.checked })}
className="w-4 h-4 text-brand-purple border-[var(--neutral-800)] rounded focus:ring-brand-purple "
className="w-4 h-4 ui-checkbox text-brand-purple border-[var(--neutral-800)] rounded focus:ring-brand-purple "
/>
<label htmlFor="published" className="text-sm font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Publish event (make visible to members)
@@ -267,7 +267,7 @@ const AdminEvents = () => {
</Button>
<Button
type="submit"
className="flex-1 bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full"
className="btn-lavender flex-1"
>
{editingEvent ? 'Update Event' : 'Create Event'}
</Button>
@@ -408,7 +408,7 @@ const AdminEvents = () => {
</p>
<Button
onClick={() => setIsCreateDialogOpen(true)}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8"
className="btn-lavender px-8"
>
<Plus className="mr-2 h-5 w-5" />
Create Event

View File

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

View File

@@ -184,7 +184,7 @@ const AdminGallery = () => {
{/* Empty State Message */}
{events.length === 0 && (
<div className="mt-4 p-4 bg-[var(--lavender-300)] border-2 border-[var(--neutral-800)] rounded-xl">
<div className="mt-4 p-4 bg-[var(--lavender-300)] dark:bg-brand-lavender/10 dark:border-transparent border-2 border-[var(--neutral-800)] rounded-xl">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
<div className="flex-1">
@@ -196,7 +196,7 @@ const AdminGallery = () => {
</p>
<Link to="/admin/events">
<Button
className="btn-lavender text-sm"
className="btn-lavender text-sm"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Calendar className="h-4 w-4 mr-2" />

View File

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

View File

@@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { 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 {
@@ -14,22 +13,30 @@ import {
DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu';
import { toast } from 'sonner';
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown } from 'lucide-react';
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog';
import InviteMemberDialog from '../../components/InviteMemberDialog';
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, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('active');
const {
users,
filteredUsers,
loading,
searchQuery,
setSearchQuery,
filterValue: statusFilter,
setFilterValue: setStatusFilter,
refetch,
} = useMembers();
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
const [statusChanging, setStatusChanging] = useState(null);
@@ -40,53 +47,13 @@ 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 = () => {
fetchMembers(); // Refresh list
refetch(); // Refresh list
};
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
@@ -107,7 +74,7 @@ const AdminMembers = () => {
try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success('Member status updated successfully');
fetchMembers(); // Refresh list
refetch(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update status');
} finally {
@@ -199,27 +166,6 @@ const AdminMembers = () => {
};
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Pending Email', variant: 'orange2' },
pending_validation: { label: 'Pending Validation', variant: 'gray' },
pre_validated: { label: 'Pre-Validated', variant: 'green' },
payment_pending: { label: 'Payment Pending', variant: 'orange' },
active: { label: 'Active', variant: 'green' },
inactive: { label: 'Inactive', variant: 'gray2' },
canceled: { label: 'Canceled', variant: 'red' },
expired: { label: 'Expired', variant: 'red2' },
abandoned: { label: 'Abandoned', variant: 'gray3' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge variant={statusConfig.variant} className={` px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
const getReminderInfo = (user) => {
const emailReminders = user.email_verification_reminders_sent || 0;
const eventReminders = user.event_attendance_reminders_sent || 0;
@@ -243,7 +189,7 @@ const AdminMembers = () => {
return (
<>
<div className="mb-8">
<div className="flex justify-between items-start mb-4">
<div className="flex flex-col md:flex-row justify-between items-start mb-4">
<div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Management
@@ -252,7 +198,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>
@@ -319,31 +265,40 @@ const AdminMembers = () => {
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
<p className="text-4xl 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 dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p>
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{users.filter(u => u.status === 'active').length}
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{users.filter(u => u.status === 'payment_pending').length}
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Inactive</p>
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{users.filter(u => u.status === 'inactive').length}
</p>
</Card>
<div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
Quick Overview
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="Active"
value={users.filter(u => u.status === 'active').length}
icon={CheckCircle}
iconBgClass="text-[var(--green-light)]"
dataTestId="stat-active-members"
/>
<StatCard
title="Payment Pending"
value={users.filter(u => u.status === 'payment_pending').length}
icon={CreditCard}
iconBgClass="text-brand-light-orange"
dataTestId="stat-payment-pending-members"
/>
<StatCard
title="Inactive"
value={users.filter(u => u.status === 'inactive').length}
icon={CircleMinus}
iconBgClass=" text-brand-pink"
dataTestId="stat-inactive-members"
/>
<StatCard
title="Total Members"
value={users.length}
icon={Users}
iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
dataTestId="stat-total-members"
/>
</div>
</div>
{/* Filters */}
@@ -368,11 +323,9 @@ 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>
@@ -385,145 +338,150 @@ const AdminMembers = () => {
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow"
data-testid={`member-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1">
{/* Avatar */}
<div className="h-14 w-14 rounded-full bg-[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]}
{filteredUsers.map((user) => {
const joinedDate = user.created_at;
const memberDate = user.member_since;
return (
<Card
key={user.id}
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow"
data-testid={`member-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1">
{/* Avatar */}
<div className="h-14 w-14 rounded-full bg-[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.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>
{user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p>
)}
</div>
{/* Reminder Info */}
{(() => {
const reminderInfo = getReminderInfo(user);
if (reminderInfo.totalReminders > 0) {
return (
<div className="mt-4 p-3 bg-[var(--lavender-500)] rounded-lg border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-[var(--orange-light)]" />
<span className="text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{reminderInfo.totalReminders} reminder{reminderInfo.totalReminders !== 1 ? 's' : ''} sent
{reminderInfo.totalReminders >= 3 && (
<Badge className="ml-2 bg-[var(--orange-light)] text-white px-2 py-0.5 rounded-full text-xs">
Needs attention
</Badge>
)}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{reminderInfo.emailReminders > 0 && (
<p>
<Mail className="inline h-3 w-3 mr-1" />
{reminderInfo.emailReminders} email verification
</p>
)}
{reminderInfo.eventReminders > 0 && (
<p>
<Calendar className="inline h-3 w-3 mr-1" />
{reminderInfo.eventReminders} event attendance
</p>
)}
{reminderInfo.paymentReminders > 0 && (
<p>
<Clock className="inline h-3 w-3 mr-1" />
{reminderInfo.paymentReminders} payment
</p>
)}
{reminderInfo.renewalReminders > 0 && (
<p>
<CheckCircle className="inline h-3 w-3 mr-1" />
{reminderInfo.renewalReminders} renewal
</p>
)}
</div>
{reminderInfo.lastReminderAt && (
<p className="mt-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Last reminder: {new Date(reminderInfo.lastReminderAt).toLocaleDateString()} at {new Date(reminderInfo.lastReminderAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
);
}
return null;
})()}
</div>
</div>
{/* 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>
{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>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p>
{/* Actions */}
<div className="flex flex-col gap-3">
<div className="flex gap-2 flex-wrap">
<Link to={`/admin/users/${user.id}`}>
<Button
variant="outline"
size="sm"
className=""
>
<Eye className="h-4 w-4 mr-1" />
View Profile
</Button>
</Link>
{/* Show Activate Payment button for payment_pending users */}
{user.status === 'payment_pending' && (
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
)}
</div>
{/* Reminder Info */}
{(() => {
const reminderInfo = getReminderInfo(user);
if (reminderInfo.totalReminders > 0) {
return (
<div className="mt-4 p-3 bg-[var(--lavender-500)] rounded-lg border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-[var(--orange-light)]" />
<span className="text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{reminderInfo.totalReminders} reminder{reminderInfo.totalReminders !== 1 ? 's' : ''} sent
{reminderInfo.totalReminders >= 3 && (
<Badge className="ml-2 bg-[var(--orange-light)] text-white px-2 py-0.5 rounded-full text-xs">
Needs attention
</Badge>
)}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{reminderInfo.emailReminders > 0 && (
<p>
<Mail className="inline h-3 w-3 mr-1" />
{reminderInfo.emailReminders} email verification
</p>
)}
{reminderInfo.eventReminders > 0 && (
<p>
<Calendar className="inline h-3 w-3 mr-1" />
{reminderInfo.eventReminders} event attendance
</p>
)}
{reminderInfo.paymentReminders > 0 && (
<p>
<Clock className="inline h-3 w-3 mr-1" />
{reminderInfo.paymentReminders} payment
</p>
)}
{reminderInfo.renewalReminders > 0 && (
<p>
<CheckCircle className="inline h-3 w-3 mr-1" />
{reminderInfo.renewalReminders} renewal
</p>
)}
</div>
{reminderInfo.lastReminderAt && (
<p className="mt-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Last reminder: {new Date(reminderInfo.lastReminderAt).toLocaleDateString()} at {new Date(reminderInfo.lastReminderAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
);
}
return null;
})()}
{/* Status Management */}
<div className="flex items-center gap-2">
<span className="text-sm text-brand-purple dark:text-brand-lavender whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Change Status:
</span>
<Select
value={user.status}
onValueChange={(newStatus) => handleStatusChangeRequest(user.id, user.status, newStatus, user)}
disabled={statusChanging === user.id}
>
<SelectTrigger className="w-[180px] h-9 border-[var(--neutral-800)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-3">
<div className="flex gap-2 flex-wrap">
<Link to={`/admin/users/${user.id}`}>
<Button
variant="outline"
size="sm"
className=""
>
<Eye className="h-4 w-4 mr-1" />
View Profile
</Button>
</Link>
{/* Show Activate Payment button for payment_pending users */}
{user.status === 'payment_pending' && (
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
)}
</div>
{/* Status Management */}
<div className="flex items-center gap-2">
<span className="text-sm text-brand-purple dark:text-brand-lavender whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Change Status:
</span>
<Select
value={user.status}
onValueChange={(newStatus) => handleStatusChangeRequest(user.id, user.status, newStatus, user)}
disabled={statusChanging === user.id}
>
<SelectTrigger className="w-[180px] h-9 border-[var(--neutral-800)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</Card>
))}
</Card>
);
})}
</div>
) : (
<div className="text-center py-20">
@@ -560,19 +518,19 @@ const AdminMembers = () => {
<CreateMemberDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={fetchMembers}
onSuccess={refetch}
/>
<InviteStaffDialog
<InviteMemberDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
onSuccess={fetchMembers}
onSuccess={refetch}
/>
<WordPressImportWizard
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
onSuccess={fetchMembers}
onSuccess={refetch}
/>
</>
);

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ 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: '',
@@ -46,6 +47,15 @@ const AdminRoles = () => {
permissions: []
});
const formatRoleSlug = (value) => (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/_+/g, '-')
.replace(/^_+|_+$/g, '')
);
useEffect(() => {
fetchRoles();
fetchPermissions();
@@ -133,6 +143,7 @@ const AdminRoles = () => {
};
const handleSavePermissions = async () => {
setSavingPermissions(true);
try {
await api.put(`/admin/roles/${selectedRole.id}/permissions`, {
permission_codes: selectedPermissions
@@ -142,6 +153,8 @@ const AdminRoles = () => {
fetchRoles();
} catch (error) {
toast.error('Failed to update permissions');
} finally {
setSavingPermissions(false);
}
};
@@ -155,6 +168,14 @@ const AdminRoles = () => {
});
};
const addPermissions = (permissionCodes) => {
setSelectedPermissions(prev => [...new Set([...prev, ...permissionCodes])]);
};
const removePermissions = (permissionCodes) => {
setSelectedPermissions(prev => prev.filter(code => !permissionCodes.includes(code)));
};
const toggleModule = (module) => {
setExpandedModules(prev => ({
...prev,
@@ -185,15 +206,12 @@ const AdminRoles = () => {
return (
<div className="space-y-6">
{/* Header */}
{/* Action Bar */}
<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)}>
<p className="text-muted-foreground">
Create and manage custom roles with specific permissions
</p>
<Button className="btn-lavender" onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Role
</Button>
@@ -273,7 +291,7 @@ const AdminRoles = () => {
{/* Create Role Modal */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto scrollbar-dashboard scrollbar-dashboard">
<DialogHeader>
<DialogTitle>Create New Role</DialogTitle>
<DialogDescription>
@@ -282,8 +300,28 @@ const AdminRoles = () => {
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Role Code *</Label>
<Label>Role Name *</Label>
<Input
placeholder="e.g., Content Editor, Finance Manager"
value={formData.name}
onChange={(e) => {
const nextName = e.target.value;
setFormData(prev => {
const prevAuto = formatRoleSlug(prev.name);
const isAuto = !prev.code || prev.code === prevAuto;
return {
...prev,
name: nextName,
code: isAuto ? formatRoleSlug(nextName) : prev.code
};
});
}}
/>
</div>
<div>
<Label>Role Slug *</Label>
<Input
placeholder="e.g., content_editor, finance_manager"
value={formData.code}
@@ -294,15 +332,6 @@ 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
@@ -317,45 +346,84 @@ const AdminRoles = () => {
<p className="text-sm text-gray-600 mb-3">
Select permissions for this role. You can also add permissions later.
</p>
<div className="border rounded-lg p-4 max-h-64 overflow-y-auto">
{Object.entries(groupedPermissions).map(([module, perms]) => (
<div key={module} className="mb-4">
<button
onClick={() => toggleModule(module)}
className="flex items-center w-full text-left font-medium mb-2 hover:text-blue-600"
>
{expandedModules[module] ? (
<ChevronUp className="w-4 h-4 mr-1" />
) : (
<ChevronDown className="w-4 h-4 mr-1" />
)}
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
</button>
{expandedModules[module] && (
<div className="space-y-2 ml-5">
{perms.map(perm => (
<div key={perm.code} className="flex items-center">
<Checkbox
checked={formData.permissions.includes(perm.code)}
onCheckedChange={() => {
setFormData(prev => ({
...prev,
permissions: prev.permissions.includes(perm.code)
? prev.permissions.filter(p => p !== perm.code)
: [...prev.permissions, perm.code]
}));
}}
/>
<label className="ml-2 text-sm">
<span className="font-medium">{perm.name}</span>
<span className="text-gray-500 ml-2">({perm.code})</span>
</label>
</div>
))}
<div 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>
)}
</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>
)}
</div>
);
})}
</div>
</div>
</div>
@@ -373,7 +441,7 @@ const AdminRoles = () => {
{/* Edit Role Modal */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent>
<DialogContent >
<DialogHeader>
<DialogTitle>Edit Role</DialogTitle>
<DialogDescription>
@@ -382,10 +450,6 @@ const AdminRoles = () => {
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Role Code</Label>
<Input value={selectedRole?.code || ''} disabled />
</div>
<div>
<Label>Role Name *</Label>
@@ -395,6 +459,11 @@ const AdminRoles = () => {
/>
</div>
<div>
<Label>Role Slug</Label>
<Input value={selectedRole?.code || ''} disabled />
</div>
<div>
<Label>Description</Label>
<Textarea
@@ -417,7 +486,7 @@ const AdminRoles = () => {
{/* Manage Permissions Modal */}
<Dialog open={showPermissionsModal} onOpenChange={setShowPermissionsModal}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle>Manage Permissions: {selectedRole?.name}</DialogTitle>
<DialogDescription>
@@ -426,48 +495,77 @@ const AdminRoles = () => {
</DialogHeader>
<div className="border rounded-lg p-4">
{Object.entries(groupedPermissions).map(([module, perms]) => (
<div key={module} className="mb-6">
<button
onClick={() => toggleModule(module)}
className="flex items-center w-full text-left font-medium text-lg mb-3 hover:text-blue-600"
>
{expandedModules[module] ? (
<ChevronUp className="w-5 h-5 mr-2" />
) : (
<ChevronDown className="w-5 h-5 mr-2" />
)}
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
</button>
{expandedModules[module] && (
<div className="space-y-3 ml-7">
{perms.map(perm => (
<div key={perm.code} className="flex items-start">
<Checkbox
checked={selectedPermissions.includes(perm.code)}
onCheckedChange={() => togglePermission(perm.code)}
/>
<div className="ml-3">
<label className="font-medium text-sm">
{perm.name}
</label>
<p className="text-xs text-gray-500">{perm.description}</p>
<span className="text-xs text-gray-400">{perm.code}</span>
</div>
</div>
))}
{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>
)}
</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>
)}
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPermissionsModal(false)}>
Cancel
</Button>
<Button onClick={handleSavePermissions}>
Save Permissions
<Button onClick={handleSavePermissions} disabled={savingPermissions}>
{savingPermissions ? 'Saving...' : 'Save Permissions'}
</Button>
</DialogFooter>
</DialogContent>
@@ -491,6 +589,15 @@ 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

@@ -0,0 +1,495 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { AlertCircle, CheckCircle, Settings as SettingsIcon, RefreshCw, Zap, Edit, Save, X, Copy, Eye, EyeOff, ExternalLink } from 'lucide-react';
import api from '../../utils/api';
import { toast } from 'sonner';
export default function AdminSettings() {
const [stripeStatus, setStripeStatus] = useState(null);
const [loadingStatus, setLoadingStatus] = useState(true);
const [testing, setTesting] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [saving, setSaving] = useState(false);
// Form state
const [formData, setFormData] = useState({
secret_key: '',
webhook_secret: ''
});
// Show/hide sensitive values
const [showSecretKey, setShowSecretKey] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
useEffect(() => {
fetchStripeStatus();
}, []);
const fetchStripeStatus = async () => {
setLoadingStatus(true);
try {
const response = await api.get('/admin/settings/stripe/status');
setStripeStatus(response.data);
} catch (error) {
console.error('Failed to fetch Stripe status:', error);
toast.error('Failed to load Stripe status');
} finally {
setLoadingStatus(false);
}
};
const handleTestConnection = async () => {
setTesting(true);
try {
const response = await api.post('/admin/settings/stripe/test-connection');
toast.success(response.data.message);
} catch (error) {
const message = error.response?.data?.detail || 'Connection test failed';
toast.error(message);
} finally {
setTesting(false);
}
};
const handleEditClick = () => {
setIsEditing(true);
setFormData({
secret_key: '',
webhook_secret: ''
});
};
const handleCancelEdit = () => {
setIsEditing(false);
setFormData({
secret_key: '',
webhook_secret: ''
});
setShowSecretKey(false);
setShowWebhookSecret(false);
};
const handleSave = async () => {
// Validate inputs
if (!formData.secret_key || !formData.webhook_secret) {
toast.error('Both Secret Key and Webhook Secret are required');
return;
}
if (!formData.secret_key.startsWith('sk_test_') && !formData.secret_key.startsWith('sk_live_')) {
toast.error('Invalid Secret Key format. Must start with sk_test_ or sk_live_');
return;
}
if (!formData.webhook_secret.startsWith('whsec_')) {
toast.error('Invalid Webhook Secret format. Must start with whsec_');
return;
}
setSaving(true);
try {
await api.put('/admin/settings/stripe', formData);
toast.success('Stripe settings updated successfully');
setIsEditing(false);
setFormData({
secret_key: '',
webhook_secret: ''
});
setShowSecretKey(false);
setShowWebhookSecret(false);
// Refresh status
await fetchStripeStatus();
} catch (error) {
const message = error.response?.data?.detail || 'Failed to update Stripe settings';
toast.error(message);
} finally {
setSaving(false);
}
};
const copyToClipboard = (text, label) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
};
if (loadingStatus) {
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-8 w-8 animate-spin text-brand-purple" />
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Stripe Integration Card */}
<Card className="border-2 border-[var(--lavender-200)] shadow-sm">
<CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]">
<div className="flex justify-between items-start">
<div>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-brand-purple" />
Stripe Integration
</CardTitle>
<CardDescription>
Payment processing and subscription management
</CardDescription>
</div>
{!isEditing && (
<Button
onClick={handleEditClick}
variant="outline"
className="border-2 border-brand-purple text-brand-purple hover:bg-[#f1eef9] rounded-full"
>
<Edit className="h-4 w-4 mr-2" />
Edit Settings
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{isEditing ? (
/* Edit Mode */
<div className="space-y-6">
{/* Secret Key Input */}
<div className="space-y-2">
<Label htmlFor="secret_key">Stripe Secret Key</Label>
<div className="relative">
<Input
id="secret_key"
type={showSecretKey ? 'text' : 'password'}
value={formData.secret_key}
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
placeholder="sk_test_... or sk_live_..."
className="pr-10"
/>
<button
type="button"
onClick={() => setShowSecretKey(!showSecretKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showSecretKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-gray-500">
Get this from your Stripe Dashboard Developers API keys
</p>
</div>
{/* Webhook Secret Input */}
<div className="space-y-2">
<Label htmlFor="webhook_secret">Stripe Webhook Secret</Label>
<div className="relative">
<Input
id="webhook_secret"
type={showWebhookSecret ? 'text' : 'password'}
value={formData.webhook_secret}
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })}
placeholder="whsec_..."
className="pr-10"
/>
<button
type="button"
onClick={() => setShowWebhookSecret(!showWebhookSecret)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showWebhookSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-gray-500">
Get this from your Stripe Dashboard Developers Webhooks Add endpoint
</p>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t-2 border-gray-100">
<Button
onClick={handleCancelEdit}
variant="outline"
className="border-2 border-gray-300 rounded-full"
disabled={saving}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-brand-purple hover:bg-[var(--purple-dark)] text-white rounded-full"
>
{saving ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</Button>
</div>
</div>
) : (
/* View Mode */
<>
{/* Status Display */}
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Configuration Status</p>
<p className="text-sm text-gray-600">Credentials stored in database (encrypted)</p>
</div>
<div className="flex items-center gap-2">
{stripeStatus?.configured ? (
<>
<CheckCircle className="h-5 w-5 text-green-600" />
<span className="text-green-600 font-semibold">Configured</span>
</>
) : (
<>
<AlertCircle className="h-5 w-5 text-amber-600" />
<span className="text-amber-600 font-semibold">Not Configured</span>
</>
)}
</div>
</div>
{stripeStatus?.configured && (
<>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Environment</p>
<p className="text-sm text-gray-600">Detected from secret key prefix</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
stripeStatus.environment === 'live'
? 'bg-green-100 text-green-800 border-2 border-green-300'
: 'bg-blue-100 text-blue-800 border-2 border-blue-300'
}`}>
{stripeStatus.environment === 'live' ? 'Live' : 'Test'}
</span>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Secret Key</p>
<p className="text-sm text-gray-600 font-mono">
{stripeStatus.secret_key_prefix}...
</p>
</div>
<CheckCircle className="h-5 w-5 text-green-600" />
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Webhook Secret</p>
<p className="text-sm text-gray-600">Webhook endpoint configuration</p>
</div>
{stripeStatus.webhook_secret_set ? (
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<span className="text-green-600 font-semibold text-sm">Set</span>
</div>
) : (
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-600" />
<span className="text-amber-600 font-semibold text-sm">Not Set</span>
</div>
)}
</div>
{/* Webhook URL */}
<div className="p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
Webhook URL
</p>
<p className="text-sm text-blue-700 mb-2">
Configure this webhook endpoint in your Stripe Dashboard:
</p>
<div className="bg-white p-2 rounded border border-blue-300 font-mono text-sm break-all">
{stripeStatus.webhook_url}
</div>
</div>
<Button
onClick={() => copyToClipboard(stripeStatus.webhook_url, 'Webhook URL')}
variant="outline"
size="sm"
className="border-blue-300 text-blue-700 hover:bg-blue-100"
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 text-xs text-blue-600">
<p className="font-semibold mb-1">Webhook Events to Configure:</p>
<div className="space-y-2">
<div>
<p className="font-medium text-blue-700"> Required (Configure in Stripe):</p>
<ul className="list-disc list-inside ml-2">
<li>checkout.session.completed - Handles subscriptions & donations</li>
</ul>
</div>
<div className="opacity-80">
<p className="font-medium text-blue-700">🔔 Automatically Triggered:</p>
<ul className="list-disc list-inside ml-2 text-xs">
<li>payment_intent.created</li>
<li>payment_intent.succeeded</li>
<li>charge.succeeded</li>
<li>charge.updated</li>
</ul>
<p className="text-xs italic mt-1">These fire automatically with checkout.session.completed</p>
</div>
<div className="opacity-70">
<p className="font-medium text-blue-700">🔄 Coming Soon (Recurring Subscriptions):</p>
<ul className="list-disc list-inside ml-2">
<li>invoice.payment_succeeded</li>
<li>invoice.payment_failed</li>
<li>customer.subscription.updated</li>
<li>customer.subscription.deleted</li>
</ul>
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Configuration Instructions (Not Configured) */}
{!stripeStatus?.configured && (
<>
<div className="p-4 bg-amber-50 border-2 border-amber-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-amber-900 mb-2">Configuration Required</p>
<p className="text-amber-700 mb-2">
Click "Edit Settings" above to configure your Stripe credentials.
</p>
<p className="text-amber-700">
Get your API keys from{' '}
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener noreferrer"
className="font-semibold underline"
>
Stripe Dashboard
</a>
</p>
</div>
</div>
</div>
{/* Webhook URL Info (Always visible) */}
<div className="p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
Webhook URL Configuration
</p>
<p className="text-sm text-blue-700 mb-2">
After configuring your API keys, set up this webhook endpoint in your Stripe Dashboard:
</p>
<div className="bg-white p-2 rounded border border-blue-300 font-mono text-sm break-all">
{stripeStatus?.webhook_url || 'http://localhost:8000/api/webhooks/stripe'}
</div>
</div>
<Button
onClick={() => copyToClipboard(stripeStatus?.webhook_url || 'http://localhost:8000/api/webhooks/stripe', 'Webhook URL')}
variant="outline"
size="sm"
className="border-blue-300 text-blue-700 hover:bg-blue-100"
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 text-xs text-blue-600">
<p className="font-semibold mb-1">Webhook Events to Configure:</p>
<div className="space-y-2">
<div>
<p className="font-medium text-blue-700"> Required (Configure in Stripe):</p>
<ul className="list-disc list-inside ml-2">
<li>checkout.session.completed - Handles subscriptions & donations</li>
</ul>
</div>
<div className="opacity-80">
<p className="font-medium text-blue-700">🔔 Automatically Triggered:</p>
<ul className="list-disc list-inside ml-2 text-xs">
<li>payment_intent.created</li>
<li>payment_intent.succeeded</li>
<li>charge.succeeded</li>
<li>charge.updated</li>
</ul>
<p className="text-xs italic mt-1">These fire automatically with checkout.session.completed</p>
</div>
<div className="opacity-70">
<p className="font-medium text-blue-700">🔄 Coming Soon (Recurring Subscriptions):</p>
<ul className="list-disc list-inside ml-2">
<li>invoice.payment_succeeded</li>
<li>invoice.payment_failed</li>
<li>customer.subscription.updated</li>
<li>customer.subscription.deleted</li>
</ul>
</div>
</div>
</div>
</div>
</>
)}
{/* Test Connection Button */}
{stripeStatus?.configured && (
<div className="flex justify-end gap-3 pt-4 border-t-2 border-gray-100">
<Button
onClick={fetchStripeStatus}
variant="outline"
className="border-2 border-gray-300 rounded-full"
disabled={loadingStatus}
>
<RefreshCw className={`h-4 w-4 mr-2 ${loadingStatus ? 'animate-spin' : ''}`} />
Refresh Status
</Button>
<Button
onClick={handleTestConnection}
disabled={testing}
className="bg-brand-purple hover:bg-[var(--purple-dark)] text-white rounded-full"
>
{testing ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Test Connection
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Future Settings Sections Placeholder */}
<div className="mt-6 p-6 border-2 border-dashed border-gray-300 rounded-lg text-center text-gray-500">
<p className="text-sm">Additional settings sections will be added here</p>
<p className="text-xs mt-1">(Email, Storage, Notifications, etc.)</p>
</div>
</div>
);
}

View File

@@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { 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';
@@ -12,72 +11,39 @@ 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 } from 'lucide-react';
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX, ShieldIcon } from 'lucide-react';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { CircleMinus, CreditCard, Users } from 'lucide-react';
import { useStaff } from '../../hooks/use-users';
const AdminStaff = () => {
const navigate = useNavigate();
const { hasPermission, user } = useAuth();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
const {
users,
filteredUsers,
loading,
searchQuery,
setSearchQuery,
filterValue: roleFilter,
setFilterValue: setRoleFilter,
refetch,
} = useStaff({
initialFilter: 'all',
filterKey: 'role',
});
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`);
fetchStaff(); // Refresh list
refetch(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update user status');
}
@@ -91,48 +57,19 @@ const AdminStaff = () => {
try {
await api.delete(`/admin/users/${userId}`);
toast.success('User deleted successfully');
fetchStaff(); // Refresh list
refetch(); // 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 justify-between items-start mb-4">
<div className="flex flex-col md:flex-row 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
@@ -141,7 +78,8 @@ 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)}
@@ -165,42 +103,52 @@ const AdminStaff = () => {
</div>
{/* Stats */}
<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 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>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-8">
<TabsList className="grid w-full grid-cols-2 mb-8">
<TabsTrigger value="staff-list" className="text-lg py-3">
<UserCog className="h-5 w-5 mr-2" />
<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" />
Staff Members
</TabsTrigger>
<TabsTrigger value="pending-invitations" className="text-lg py-3 ">
<Mail className="h-5 w-5 mr-2" />
<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" />
Pending Invitations
</TabsTrigger>
</TabsList>
@@ -242,87 +190,90 @@ const AdminStaff = () => {
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
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>
{getRoleBadge(user.role)}
{getStatusBadge(user.status)}
{filteredUsers.map((user) => {
const joinedDate = user.member_since || user.created_at;
return (
<Card
key={user.id}
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>
<div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.last_login && (
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
)}
{/* 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>
</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') && (
{/* Actions */}
<div className="flex gap-2 flex-wrap">
<Button
onClick={() => handleToggleStatus(user.id, user.status)}
onClick={() => navigate(`/admin/users/${user.id}`)}
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'
}`}
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-4 py-2"
>
{user.status === 'active' ? (
<>
<UserX className="h-4 w-4 mr-2" />
Deactivate
</>
) : (
<>
<UserCheck className="h-4 w-4 mr-2" />
Activate
</>
)}
<Edit className="h-4 w-4 mr-2" />
Manage
</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>
)}
{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>
</div>
</Card>
))}
</Card>
);
})}
</div>
) : (
<div className="text-center py-20">
@@ -348,7 +299,7 @@ const AdminStaff = () => {
<CreateStaffDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={fetchStaff}
onSuccess={refetch}
/>
<InviteStaffDialog

View File

@@ -19,7 +19,6 @@ import {
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
import { Badge } from '../../components/ui/badge';
import api from '../../utils/api';
import { toast } from 'sonner';
import {
@@ -35,7 +34,15 @@ import {
Download,
FileDown,
AlertTriangle,
Info
Info,
Repeat,
ChevronDown,
ChevronUp,
ExternalLink,
Copy
} from 'lucide-react';
import {
DropdownMenu,
@@ -43,6 +50,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu';
import StatusBadge from '@/components/StatusBadge';
import CreateSubscriptionDialog from '@/components/CreateSubscriptionDialog';
import SubscriptionsTable from '@/components/admin/SubscriptionsTable';
const AdminSubscriptions = () => {
const { hasPermission } = useAuth();
@@ -55,6 +65,10 @@ const AdminSubscriptions = () => {
const [statusFilter, setStatusFilter] = useState('all');
const [planFilter, setPlanFilter] = useState('all');
const [exporting, setExporting] = useState(false);
const [expandedRows, setExpandedRows] = useState(new Set());
//create subsdcription dialog state
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Edit subscription dialog state
const [editDialogOpen, setEditDialogOpen] = useState(false);
@@ -265,15 +279,40 @@ Proceed with activation?`;
});
};
const getStatusBadgeVariant = (status) => {
const variants = {
active: 'default',
cancelled: 'destructive',
expired: 'secondary'
};
return variants[status] || 'outline';
const formatDateTime = (dateString) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const toggleRowExpansion = (subscriptionId) => {
setExpandedRows((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(subscriptionId)) {
newExpanded.delete(subscriptionId);
} else {
newExpanded.add(subscriptionId);
}
return newExpanded;
});
};
const copyToClipboard = async (text, label) => {
try {
await navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
} catch (error) {
toast.error('Failed to copy to clipboard');
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
@@ -283,504 +322,432 @@ Proceed with activation?`;
}
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Management
</h1>
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View and manage all member subscriptions
</p>
</div>
<>
<div className="space-y-8">
{/* Header */}
<div className='flex justify-between'>
{/* Stats Cards */}
<div className="grid md:grid-cols-4 gap-6">
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Subscriptions
</p>
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{stats.total || 0}
</p>
</div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<CreditCard className="h-6 w-6 text-brand-purple " />
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Active Members
</p>
<p className="text-3xl font-bold text-[var(--green-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{stats.active || 0}
</p>
</div>
<div className="p-3 bg-[var(--green-light)]/10 rounded-full">
<TrendingUp className="h-6 w-6 text-[var(--green-light)]" />
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Revenue
</p>
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(stats.total_revenue || 0)}
</p>
</div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<DollarSign className="h-6 w-6 text-brand-purple " />
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Donations
</p>
<p className="text-3xl font-bold text-[var(--orange-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(stats.total_donations || 0)}
</p>
</div>
<div className="p-3 bg-[var(--orange-light)]/10 rounded-full">
<Heart className="h-6 w-6 text-[var(--orange-light)]" />
</div>
</div>
</Card>
</div>
{/* Search & Filter Bar */}
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="grid md:grid-cols-3 gap-4">
{/* Search */}
<div className="md:col-span-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-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 "
/>
</div>
</div>
{/* Status Filter */}
<div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Management
</h1>
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View and manage all member subscriptions
</p>
</div>
{/* Plan Filter */}
<div>
<Select value={planFilter} onValueChange={setPlanFilter}>
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="All Plans" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Plans</SelectItem>
{plans.map(plan => (
<SelectItem key={plan.id} value={plan.id}>{plan.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
</div>
{/* Export Dropdown */}
{hasPermission('subscriptions.export') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={exporting}
className="btn-green py-2 flex items-center gap-2"
>
<Download className="h-4 w-4" />
{exporting ? 'Exporting...' : 'Export'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-background rounded-xl border-2 border-[var(--neutral-800)] shadow-lg">
<DropdownMenuItem
onClick={() => handleExport('all')}
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
>
<FileDown className="h-4 w-4 mr-2 text-brand-purple " />
<span className="text-[var(--purple-ink)]">Export All Subscriptions</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport('current')}
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
>
<FileDown className="h-4 w-4 mr-2 text-brand-purple " />
<span className="text-[var(--purple-ink)]">Export Current View</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Card>
{/* Subscriptions Table */}
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
{/* Mobile Card View */}
<div className="md:hidden p-4 space-y-4">
{filteredSubscriptions.length > 0 ? (
filteredSubscriptions.map((sub) => (
<Card key={sub.id} className="p-4 border border-[var(--neutral-800)] bg-[var(--lavender-400)]/30">
<div className="space-y-3">
{/* Member Info */}
<div className="flex justify-between items-start border-b border-[var(--neutral-800)] pb-3">
<div className="flex-1">
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.user.first_name} {sub.user.last_name}
</p>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.user.email}
</p>
</div>
<Badge variant={getStatusBadgeVariant(sub.status)}>{sub.status}</Badge>
</div>
{/* Plan & Period */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-brand-purple mb-1">Plan</p>
<p className="font-medium text-[var(--purple-ink)]">{sub.plan.name}</p>
<p className="text-xs text-brand-purple ">{sub.plan.billing_cycle}</p>
</div>
<div>
<p className="text-xs text-brand-purple mb-1">Period</p>
<p className="text-[var(--purple-ink)]">
{new Date(sub.current_period_start).toLocaleDateString()} -
{new Date(sub.current_period_end).toLocaleDateString()}
</p>
</div>
</div>
{/* Pricing */}
<div className="grid grid-cols-3 gap-2 text-sm bg-background/50 p-3 rounded">
<div>
<p className="text-xs text-brand-purple mb-1">Base Fee</p>
<p className="font-medium text-[var(--purple-ink)]">
${(sub.base_fee_cents / 100).toFixed(2)}
</p>
</div>
<div>
<p className="text-xs text-brand-purple mb-1">Donation</p>
<p className="font-medium text-[var(--purple-ink)]">
${(sub.donation_cents / 100).toFixed(2)}
</p>
</div>
<div>
<p className="text-xs text-brand-purple mb-1">Total</p>
<p className="font-semibold text-[var(--purple-ink)]">
${(sub.total_cents / 100).toFixed(2)}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
{hasPermission('subscriptions.edit') && (
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(sub)}
className="flex-1 text-brand-purple hover:bg-[var(--neutral-800)]"
>
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
)}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button
size="sm"
variant="outline-destructive"
onClick={() => handleCancelSubscription(sub.id)}
className="flex-1 "
>
<XCircle className="h-4 w-4 mr-2" />
Cancel
</Button>
)}
</div>
</div>
</Card>
))
) : (
<div className="p-12 text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No subscriptions found
</div>
{hasPermission('users.create') && (
<Button
onClick={() => setCreateDialogOpen(true)}
className="btn-util-green "
>
<Repeat className="h-5 w-5 mr-2" />
Create Subscription
</Button>
)}
</div>
{/* Desktop Table View */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-[var(--neutral-800)]/20 border-b border-[var(--neutral-800)]">
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
Member
</th>
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
Plan
</th>
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
Status
</th>
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
Period
</th>
<th className="text-right p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
Base Fee
</th>
<th className="text-right p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
Donation
</th>
<th className="text-right p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
Total
</th>
<th className="text-center p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
Actions
</th>
</tr>
</thead>
<tbody>
{filteredSubscriptions.length > 0 ? (
filteredSubscriptions.map((sub) => (
<tr key={sub.id} className="border-b border-[var(--neutral-800)] hover:bg-[var(--lavender-400)] transition-colors">
<td className="p-4">
<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>
</td>
<td className="p-4">
<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>
</td>
<td className="p-4">
<Badge variant={getStatusBadgeVariant(sub.status)}>
{sub.status}
</Badge>
</td>
<td className="p-4">
<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>
</td>
<td className="p-4 text-right text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(sub.base_subscription_cents || 0)}
</td>
<td className="p-4 text-right text-[var(--orange-light)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(sub.donation_cents || 0)}
</td>
<td className="p-4 text-right font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(sub.amount_paid_cents || 0)}
</td>
<td className="p-4">
<div className="flex items-center justify-center gap-2">
{hasPermission('subscriptions.edit') && (
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(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={() => handleCancelSubscription(sub.id)}
className=""
>
<XCircle className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="8" className="p-12 text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No subscriptions found
</td>
</tr>
)}
</tbody>
</table>
{/* Stats Cards */}
<div className="grid md:grid-cols-4 gap-6">
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Subscriptions
</p>
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{stats.total || 0}
</p>
</div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<CreditCard className="h-6 w-6 text-brand-purple " />
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Active Members
</p>
<p className="text-3xl font-bold text-[var(--green-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{stats.active || 0}
</p>
</div>
<div className="p-3 bg-[var(--green-light)]/10 rounded-full">
<TrendingUp className="h-6 w-6 text-[var(--green-light)]" />
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Revenue
</p>
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(stats.total_revenue || 0)}
</p>
</div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<DollarSign className="h-6 w-6 text-brand-purple " />
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Donations
</p>
<p className="text-3xl font-bold text-[var(--orange-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(stats.total_donations || 0)}
</p>
</div>
<div className="p-3 bg-[var(--orange-light)]/10 rounded-full">
<Heart className="h-6 w-6 text-[var(--orange-light)]" />
</div>
</div>
</Card>
</div>
</Card>
{/* Edit Subscription Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px] bg-background rounded-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Edit Subscription
</DialogTitle>
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update subscription status or end date for {selectedSubscription?.user.first_name} {selectedSubscription?.user.last_name}
</DialogDescription>
</DialogHeader>
{/* Search & Filter Bar */}
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="grid md:grid-cols-3 gap-4">
{/* Search */}
<div className="md:col-span-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-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 "
/>
</div>
</div>
<div className="space-y-6 py-4">
{/* Status */}
<div className="space-y-2">
<Label htmlFor="status" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Status
</Label>
<Select
value={editFormData.status}
onValueChange={(value) => setEditFormData({ ...editFormData, status: value })}
>
{/* Status Filter */}
<div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Select status" />
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
{/* Warning Box - Show when status is different */}
{selectedSubscription && editFormData.status !== selectedSubscription.status && (
<div className={`mt-3 p-4 rounded-xl border-2 ${editFormData.status === 'cancelled'
? 'bg-red-50 border-red-300'
: editFormData.status === 'expired'
? 'bg-orange-50 border-orange-300'
: 'bg-green-50 border-green-300'
}`}>
<div className="flex items-start gap-3">
{editFormData.status === 'cancelled' || editFormData.status === 'expired' ? (
<AlertTriangle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
) : (
<Info className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<p className="font-semibold text-sm mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{editFormData.status === 'cancelled' && 'Critical: This will cancel the subscription'}
{editFormData.status === 'expired' && 'Warning: This will mark subscription as expired'}
{editFormData.status === 'active' && 'This will activate the subscription'}
</p>
<ul className="text-xs space-y-1 list-disc list-inside" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{editFormData.status === 'cancelled' && (
<>
<li>User status will be set to INACTIVE</li>
<li>Member access will be removed immediately</li>
<li>All future billing will be stopped</li>
</>
)}
{editFormData.status === 'expired' && (
<>
<li>User status will be set to INACTIVE</li>
<li>Member access will be removed</li>
<li>Subscription will be marked as ended</li>
</>
)}
{editFormData.status === 'active' && (
<>
<li>User status will be set to ACTIVE</li>
<li>Full member access will be granted</li>
<li>Billing will resume if applicable</li>
</>
)}
</ul>
<p className="text-xs mt-2 font-medium">
Current: <span className="font-bold">{selectedSubscription.status.toUpperCase()}</span>
New: <span className="font-bold">{editFormData.status.toUpperCase()}</span>
</p>
</div>
</div>
</div>
)}
</div>
{/* End Date */}
<div className="space-y-2">
<Label htmlFor="end_date" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
End Date
</Label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input
id="end_date"
type="date"
value={editFormData.end_date}
onChange={(e) => setEditFormData({ ...editFormData, end_date: e.target.value })}
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/>
</div>
{/* Plan Filter */}
<div>
<Select value={planFilter} onValueChange={setPlanFilter}>
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="All Plans" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Plans</SelectItem>
{plans.map(plan => (
<SelectItem key={plan.id} value={plan.id}>{plan.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => setEditDialogOpen(false)}
className="rounded-full border-2 border-[var(--neutral-800)]"
disabled={isUpdating}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSaveSubscription}
disabled={isUpdating}
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)] rounded-full"
>
{isUpdating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
</div>
{/* Export Dropdown */}
{hasPermission('subscriptions.export') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={exporting}
className="btn-green py-2 flex items-center gap-2"
>
<Download className="h-4 w-4" />
{exporting ? 'Exporting...' : 'Export'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-background rounded-xl border-2 border-[var(--neutral-800)] shadow-lg">
<DropdownMenuItem
onClick={() => handleExport('all')}
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
>
<FileDown className="h-4 w-4 mr-2 text-brand-purple " />
<span className="text-[var(--purple-ink)]">Export All Subscriptions</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport('current')}
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
>
<FileDown className="h-4 w-4 mr-2 text-brand-purple " />
<span className="text-[var(--purple-ink)]">Export Current View</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Card>
{/* Subscriptions Table */}
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
{/* Mobile Card View */}
<div className="md:hidden p-4 space-y-4">
{filteredSubscriptions.length > 0 ? (
filteredSubscriptions.map((sub) => (
<Card key={sub.id} className="p-4 border border-[var(--neutral-800)] bg-[var(--lavender-400)]/30">
<div className="space-y-3">
{/* Member Info */}
<div className="flex justify-between items-start border-b border-[var(--neutral-800)] pb-3">
<div className="flex-1">
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.user.first_name} {sub.user.last_name}
</p>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.user.email}
</p>
</div>
<StatusBadge status={sub.status} />
</div>
{/* Plan & Period */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-brand-purple mb-1">Plan</p>
<p className="font-medium text-[var(--purple-ink)]">{sub.plan.name}</p>
<p className="text-xs text-brand-purple ">{sub.plan.billing_cycle}</p>
</div>
<div>
<p className="text-xs text-brand-purple mb-1">Period</p>
<p className="text-[var(--purple-ink)]">
{new Date(sub.current_period_start).toLocaleDateString()} -
{new Date(sub.current_period_end).toLocaleDateString()}
</p>
</div>
</div>
{/* Pricing */}
<div className="grid grid-cols-3 gap-2 text-sm bg-background/50 p-3 rounded">
<div>
<p className="text-xs text-brand-purple mb-1">Base Fee</p>
<p className="font-medium text-[var(--purple-ink)]">
${(sub.base_fee_cents / 100).toFixed(2)}
</p>
</div>
<div>
<p className="text-xs text-brand-purple mb-1">Donation</p>
<p className="font-medium text-[var(--purple-ink)]">
${(sub.donation_cents / 100).toFixed(2)}
</p>
</div>
<div>
<p className="text-xs text-brand-purple mb-1">Total</p>
<p className="font-semibold text-[var(--purple-ink)]">
${(sub.total_cents / 100).toFixed(2)}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
{hasPermission('subscriptions.edit') && (
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(sub)}
className="flex-1 text-brand-purple hover:bg-[var(--neutral-800)]"
>
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
)}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button
size="sm"
variant="outline-destructive"
onClick={() => handleCancelSubscription(sub.id)}
className="flex-1 "
>
<XCircle className="h-4 w-4 mr-2" />
Cancel
</Button>
)}
</div>
</div>
</Card>
))
) : (
<div className="p-12 text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No subscriptions found
</div>
)}
</div>
{/* Desktop Table View */}
<div className="hidden md:block overflow-x-auto">
<SubscriptionsTable
subscriptions={filteredSubscriptions}
expandedRows={expandedRows}
onToggleRowExpansion={toggleRowExpansion}
onEdit={handleEdit}
onCancel={handleCancelSubscription}
hasPermission={hasPermission}
formatDate={formatDate}
formatDateTime={formatDateTime}
formatPrice={formatPrice}
copyToClipboard={copyToClipboard}
/>
</div>
</Card>
{/* Edit Subscription Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px] bg-background rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader>
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Edit Subscription
</DialogTitle>
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update subscription status or end date for {selectedSubscription?.user.first_name} {selectedSubscription?.user.last_name}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Status */}
<div className="space-y-2">
<Label htmlFor="status" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Status
</Label>
<Select
value={editFormData.status}
onValueChange={(value) => setEditFormData({ ...editFormData, status: value })}
>
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
{/* Warning Box - Show when status is different */}
{selectedSubscription && editFormData.status !== selectedSubscription.status && (
<div className={`mt-3 p-4 rounded-xl border-2 ${editFormData.status === 'cancelled'
? 'bg-red-50 border-red-300'
: editFormData.status === 'expired'
? 'bg-orange-50 border-orange-300'
: 'bg-green-50 border-green-300'
}`}>
<div className="flex items-start gap-3">
{editFormData.status === 'cancelled' || editFormData.status === 'expired' ? (
<AlertTriangle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
) : (
<Info className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<p className="font-semibold text-sm mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{editFormData.status === 'cancelled' && 'Critical: This will cancel the subscription'}
{editFormData.status === 'expired' && 'Warning: This will mark subscription as expired'}
{editFormData.status === 'active' && 'This will activate the subscription'}
</p>
<ul className="text-xs space-y-1 list-disc list-inside" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{editFormData.status === 'cancelled' && (
<>
<li>User status will be set to INACTIVE</li>
<li>Member access will be removed immediately</li>
<li>All future billing will be stopped</li>
</>
)}
{editFormData.status === 'expired' && (
<>
<li>User status will be set to INACTIVE</li>
<li>Member access will be removed</li>
<li>Subscription will be marked as ended</li>
</>
)}
{editFormData.status === 'active' && (
<>
<li>User status will be set to ACTIVE</li>
<li>Full member access will be granted</li>
<li>Billing will resume if applicable</li>
</>
)}
</ul>
<p className="text-xs mt-2 font-medium">
Current: <span className="font-bold">{selectedSubscription.status.toUpperCase()}</span>
New: <span className="font-bold">{editFormData.status.toUpperCase()}</span>
</p>
</div>
</div>
</div>
)}
</div>
{/* End Date */}
<div className="space-y-2">
<Label htmlFor="end_date" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
End Date
</Label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input
id="end_date"
type="date"
value={editFormData.end_date}
onChange={(e) => setEditFormData({ ...editFormData, end_date: e.target.value })}
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => setEditDialogOpen(false)}
className="rounded-full border-2 border-[var(--neutral-800)]"
disabled={isUpdating}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSaveSubscription}
disabled={isUpdating}
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)] rounded-full"
>
{isUpdating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<CreateSubscriptionDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={fetchData}
/>
</>
);
};

View File

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

View File

@@ -5,9 +5,13 @@ import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '../../components/ui/avatar';
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera, Upload, Trash2 } from 'lucide-react';
import { Input } from '../../components/ui/input';
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera, Upload, Trash2, Shield } from 'lucide-react';
import { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import ChangeRoleDialog from '../../components/ChangeRoleDialog';
import StatusBadge from '../../components/StatusBadge';
import TransactionHistory from '../../components/TransactionHistory';
const AdminUserView = () => {
const { userId } = useParams();
@@ -16,21 +20,65 @@ const AdminUserView = () => {
const [loading, setLoading] = useState(true);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
const [subscriptions, setSubscriptions] = useState([]);
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true);
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [transactionsLoading, setTransactionsLoading] = useState(true);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingAction, setPendingAction] = useState(null);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
const [memberSince, setMemberSince] = useState('');
const [memberSinceSaving, setMemberSinceSaving] = useState(false);
const fileInputRef = useRef(null);
const [changeRoleDialogOpen, setChangeRoleDialogOpen] = useState(false);
const formatLocalDateInputValue = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatDateInputValue = (value) => {
if (!value) return '';
if (typeof value === 'string') {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value.slice(0, 10);
}
return formatLocalDateInputValue(parsed);
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return formatLocalDateInputValue(parsed);
};
const formatDateDisplayValue = (value) => {
if (!value) return 'N/A';
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day).toLocaleDateString();
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return 'N/A';
return parsed.toLocaleDateString();
};
useEffect(() => {
fetchConfig();
fetchUserProfile();
fetchSubscriptions();
fetchTransactions();
}, [userId]);
useEffect(() => {
if (user) {
setMemberSince(formatDateInputValue(user.member_since));
}
}, [user]);
const fetchUserProfile = async () => {
try {
const response = await api.get(`/admin/users/${userId}`);
@@ -43,14 +91,15 @@ const AdminUserView = () => {
}
};
const fetchSubscriptions = async () => {
const fetchTransactions = async () => {
try {
const response = await api.get(`/admin/subscriptions?user_id=${userId}`);
setSubscriptions(response.data);
setTransactionsLoading(true);
const response = await api.get(`/admin/users/${userId}/transactions`);
setTransactions(response.data);
} catch (error) {
console.error('Failed to fetch subscriptions:', error);
console.error('Failed to fetch transactions:', error);
} finally {
setSubscriptionsLoading(false);
setTransactionsLoading(false);
}
};
@@ -175,6 +224,27 @@ const AdminUserView = () => {
}
};
const handleMemberSinceSave = async () => {
if (!user) return;
setMemberSinceSaving(true);
try {
const payload = {
member_since: memberSince ? memberSince : null
};
const response = await api.put(`/admin/users/${userId}`, payload);
setUser(prev => ({
...prev,
...(response?.data || {}),
member_since: payload.member_since
}));
toast.success('Member since updated successfully');
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update member since');
} finally {
setMemberSinceSaving(false);
}
};
const getActionMessage = () => {
if (!pendingAction || !user) return {};
@@ -202,9 +272,18 @@ const AdminUserView = () => {
return {};
};
const handleRoleChanged = () => {
// Refresh user data after role change
fetchUserProfile();
};
if (loading) return <div>Loading...</div>;
if (!user) return null;
const joinedDate = user.created_at;
const memberSinceBaseline = formatDateInputValue(user.member_since);
const memberSinceHasChanges = memberSince !== memberSinceBaseline;
return (
<>
{/* Back Button */}
@@ -235,8 +314,9 @@ const AdminUserView = () => {
{user.first_name} {user.last_name}
</h1>
{/* Status & Role Badges */}
<Badge>{user.status}</Badge>
<Badge>{user.role}</Badge>
<StatusBadge status={user.status} />
<StatusBadge status={user.role} />
</div>
{/* Contact Info */}
@@ -255,7 +335,7 @@ const AdminUserView = () => {
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Joined {new Date(user.created_at).toLocaleDateString()}</span>
<span>Registered: {formatDateDisplayValue(joinedDate)}</span>
</div>
</div>
</div>
@@ -278,6 +358,15 @@ const AdminUserView = () => {
{resetPasswordLoading ? 'Resetting...' : 'Reset Password'}
</Button>
<Button
onClick={() => setChangeRoleDialogOpen(true)}
variant="outline"
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-4 py-2"
>
<Shield className="h-4 w-4 mr-2" />
Change Role
</Button>
{!user.email_verified && (
<Button
onClick={handleResendVerificationRequest}
@@ -343,10 +432,31 @@ const AdminUserView = () => {
<div>
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</label>
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(user.date_of_birth).toLocaleDateString()}
{formatDateDisplayValue(user.date_of_birth)}
</p>
</div>
<div>
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</label>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Input
type="date"
value={memberSince}
onChange={(e) => setMemberSince(e.target.value)}
className="max-w-[200px] border-[var(--neutral-800)]"
/>
<Button
type="button"
size="sm"
onClick={handleMemberSinceSave}
disabled={memberSinceSaving || !memberSinceHasChanges}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
>
{memberSinceSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
{user.partner_first_name && (
<div>
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Partner</label>
@@ -376,97 +486,17 @@ const AdminUserView = () => {
</div>
</Card>
{/* Subscription Info (if applicable) */}
{user.role === 'member' && (
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] mt-8">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Information
</h2>
{subscriptionsLoading ? (
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading subscriptions...</p>
) : subscriptions.length === 0 ? (
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No subscriptions found for this member.</p>
) : (
<div className="space-y-6">
{subscriptions.map((sub) => (
<div key={sub.id} className="p-6 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.plan.name}
</h3>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.plan.billing_cycle}
</p>
</div>
<Badge className={
sub.status === 'active' ? 'bg-[var(--green-light)] text-white' :
sub.status === 'expired' ? 'bg-red-500 text-white' :
'bg-gray-400 text-white'
}>
{sub.status}
</Badge>
</div>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(sub.start_date).toLocaleDateString()}
</p>
</div>
{sub.end_date && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(sub.end_date).toLocaleDateString()}
</p>
</div>
)}
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Base Amount</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.base_subscription_cents / 100).toFixed(2)}
</p>
</div>
{sub.donation_cents > 0 && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Donation</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.donation_cents / 100).toFixed(2)}
</p>
</div>
)}
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Paid</label>
<p className="text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.amount_paid_cents / 100).toFixed(2)}
</p>
</div>
{sub.payment_method && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.payment_method}
</p>
</div>
)}
{sub.stripe_subscription_id && (
<div className="md:col-span-2">
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Stripe Subscription ID</label>
<p className="text-[var(--purple-ink)] text-xs font-mono" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.stripe_subscription_id}
</p>
</div>
)}
</div>
</div>
))}
</div>
)}
</Card>
)}
{/* Transaction History */}
<div className="mt-8">
<TransactionHistory
subscriptions={transactions.subscriptions}
donations={transactions.donations}
totalSubscriptionCents={transactions.total_subscription_amount_cents}
totalDonationCents={transactions.total_donation_amount_cents}
loading={transactionsLoading}
isAdmin={true}
/>
</div>
{/* Admin Action Confirmation Dialog */}
<ConfirmationDialog
@@ -476,6 +506,14 @@ const AdminUserView = () => {
loading={resetPasswordLoading || resendVerificationLoading}
{...getActionMessage()}
/>
{/* Change Role Dialog */}
<ChangeRoleDialog
open={changeRoleDialogOpen}
onClose={() => setChangeRoleDialogOpen(false)}
user={user}
onSuccess={handleRoleChanged}
/>
</>
);
};

View File

@@ -2,8 +2,6 @@ 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,
@@ -20,6 +18,12 @@ import {
TableRow,
TableCell,
} from '../../components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../../components/ui/tooltip';
import {
Pagination,
PaginationContent,
@@ -30,10 +34,14 @@ import {
PaginationEllipsis,
} from '../../components/ui/pagination';
import { toast } from 'sonner';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X } from 'lucide-react';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X, FileText, XCircle } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import 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();
@@ -47,6 +55,8 @@ 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('');
@@ -60,6 +70,9 @@ const AdminValidations = () => {
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
// Resend email state
const [resendLoading, setResendLoading] = useState(null);
useEffect(() => {
fetchPendingUsers();
}, []);
@@ -235,23 +248,26 @@ const AdminValidations = () => {
}
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
pre_validated: { label: 'Pre-Validated', className: 'bg-[var(--green-light)] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
rejected: { label: 'Rejected', className: 'bg-red-100 text-red-700' }
};
const statusConfig = config[status];
return (
<Badge className={`${statusConfig.className} px-2 py-1 rounded-full text-xs`}>
{statusConfig.label}
</Badge>
);
const handleRegistrationDialog = (user) => {
setSelectedUserForView(user);
setViewRegistrationDialogOpen(true);
};
// Resend Email Handler
const handleResendVerification = async (user) => {
setResendLoading(user.id);
try {
await api.post(`/admin/users/${user.id}/resend-verification`);
toast.success(`Verification email sent to ${user.email}`);
fetchPendingUsers();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to send verification email');
} finally {
setResendLoading(null);
}
};
const handleSort = (column) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
@@ -275,6 +291,37 @@ 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 */}
@@ -287,45 +334,54 @@ const AdminValidations = () => {
</p>
</div>
{/* Stats Card */}
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
<Card className="rounded-3xl bg-brand-lavender/10 p-8 mb-8">
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
Quick Overview
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div>
<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>
<StatCard
title="Total Pending"
value={loading ? '-' : pendingUsers.length}
icon={CheckCircle}
iconBgClass="text-brand-purple"
dataTestId="stat-total-users"
/>
<StatCard
title="Awaiting Email"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
icon={CheckCircle}
iconBgClass="text-brand-purple"
dataTestId="stat-total-users"
/>
<StatCard
title="Pending Validation"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
icon={CheckCircle}
iconBgClass="text-brand-purple"
dataTestId="stat-pending-validation"
/>
<StatCard
title="Payment Pending"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
icon={CheckCircle}
iconBgClass="text-brand-purple"
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"
/>
</div>
</Card>
@@ -346,13 +402,12 @@ const AdminValidations = () => {
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectContent className="">
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Awaiting Email</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<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>
</SelectContent>
</Select>
</div>
@@ -368,14 +423,13 @@ const AdminValidations = () => {
<Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableRow className="text-md">
<TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
onClick={() => handleSort('first_name')}
>
Name {renderSortIcon('first_name')}
Member {renderSortIcon('first_name')}
</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
@@ -389,6 +443,13 @@ 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>
@@ -396,105 +457,116 @@ const AdminValidations = () => {
<TableBody>
{paginatedUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.first_name} {user.last_name}
<TableCell className=" ">
<div className='font-semibold'>
{user.first_name} {user.last_name}
</div>
<div className='text-brand-purple'>
{user.email}
</div>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>{getStatusBadge(user.status)}</TableCell>
<TableCell>{formatPhoneNumber(user.phone)}</TableCell>
<TableCell><StatusBadge status={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">
{user.status === 'rejected' ? (
<Button
onClick={() => handleReactivateUser(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]"
>
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'}
</Button>
) : user.status === 'pending_email' ? (
<>
{hasPermission('users.approve') && (
<Button
onClick={() => handleBypassAndValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
>
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
</Button>
<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>
)} */}
</>
)}
{hasPermission('users.approve') && (
</SelectContent>
</Select>
<TooltipProvider>
{/* view registration */}
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => handleRejectUser(user)}
onClick={() => handleRegistrationDialog(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"
className="border-2 border-primary text-primary hover:bg-red-50 dark:hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-1" />
Reject
<FileText className="size-4" />
</Button>
)}
</>
) : user.status === 'payment_pending' ? (
<>
{hasPermission('subscriptions.activate') && (
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="btn-light-lavender"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
) : (
<>
{hasPermission('users.approve') && (
<Button
onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]"
>
{actionLoading === user.id ? 'Validating...' : 'Validate'}
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
)}
</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>
</div>
</TableCell>
</TableRow>
@@ -619,6 +691,13 @@ const AdminValidations = () => {
user={userToReject}
loading={actionLoading !== null}
/>
{/* View Registration Dialog */}
<ViewRegistrationDialog
open={viewRegistrationDialogOpen}
onOpenChange={setViewRegistrationDialogOpen}
user={selectedUserForView}
/>
</>
);
};

View File

@@ -5,20 +5,34 @@ import MemberFooter from '../../components/MemberFooter';
import { Card } from '../../components/ui/card';
import { 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 } from 'lucide-react';
import { Scale, ExternalLink, History, Check, Search, Calendar } 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');
@@ -32,15 +46,46 @@ export default function Bylaws() {
}
};
const fetchHistory = async () => {
const fetchHistory = async (year = null) => {
try {
const response = await api.get('/bylaws/history');
const url = year ? `/bylaws/history?year=${year}` : '/bylaws/history';
const response = await api.get(url);
setHistory(response.data);
} 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',
@@ -75,6 +120,44 @@ 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 */}
@@ -124,7 +207,7 @@ export default function Bylaws() {
)}
{/* Version History Toggle */}
{history.length > 1 && (
{filteredHistory.filter(b => !b.is_current).length > 0 && (
<div className="mb-6">
<Button
onClick={() => setShowHistory(!showHistory)}
@@ -132,41 +215,48 @@ export default function Bylaws() {
className="w-full border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full flex items-center justify-center gap-2"
>
<History className="h-4 w-4" />
{showHistory ? 'Hide' : 'View'} Version History ({history.length - 1} previous {history.length - 1 === 1 ? 'version' : 'versions'})
{showHistory ? 'Hide' : 'View'} Version History ({filteredHistory.filter(b => !b.is_current).length} previous {filteredHistory.filter(b => !b.is_current).length === 1 ? 'version' : 'versions'})
</Button>
</div>
)}
{/* Version History */}
{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>
{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>
))}
</div>
</Card>
</div>
))}
</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">
<DialogContent className="max-w-7xl w-full h-[90vh] p-0 bg-black border-0 overflow-y-auto max-h-[90vh]">
{selectedImageIndex !== null && galleryImages[selectedImageIndex] && (
<div className="relative w-full h-full flex items-center justify-center">
{/* Close Button */}

View File

@@ -5,20 +5,36 @@ 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 } from 'lucide-react';
import { DollarSign, ExternalLink, TrendingUp, Search, Calendar } 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 fetchReports = async () => {
const fetchYears = async () => {
try {
const response = await api.get('/financials');
const response = await api.get('/financials/years');
setYears(response.data);
} catch (error) {
console.error('Failed to load years');
}
};
const fetchReports = async (year = null) => {
try {
setLoading(true);
const url = year ? `/financials?year=${year}` : '/financials';
const response = await api.get(url);
setReports(response.data);
} catch (error) {
toast.error('Failed to load financial reports');
@@ -27,6 +43,36 @@ export default function Financials() {
}
};
const handleYearFilter = (year) => {
setSelectedYear(year);
fetchReports(year);
};
const clearFilter = () => {
setSelectedYear(null);
fetchReports();
};
const filteredReports = reports.filter(report =>
report.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
report.year.toString().includes(searchTerm)
);
const groupByYear = (items) => {
const grouped = {};
items.forEach(item => {
const year = item.year;
if (!grouped[year]) {
grouped[year] = [];
}
grouped[year].push(item);
});
return grouped;
};
const groupedReports = groupByYear(filteredReports);
const sortedYears = Object.keys(groupedReports).sort((a, b) => b - a);
if (loading) {
return (
<div className="min-h-screen bg-background">
@@ -53,56 +99,100 @@ export default function Financials() {
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
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 */}
{reports.length === 0 ? (
{filteredReports.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 available yet
No financial reports found
</p>
</Card>
) : (
<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>
<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>
{/* 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>
{/* 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>
))}
</div>
</Card>
</div>
))}
</div>
)}
{/* Transparency Note */}
{reports.length > 0 && (
{filteredReports.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

@@ -195,7 +195,7 @@ export default function MemberCalendar() {
</Card>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
{selectedEvent && (
<>
<DialogHeader>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
@@ -15,74 +15,89 @@ 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';
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;
useEffect(() => {
fetchMembers();
const { tiers } = useMemberTiers();
const allowedRoles = useMemo(() => [], []);
const normalizeStatus = useCallback((status) => {
if (typeof status === 'string') {
return status.toLowerCase();
}
return status;
}, []);
const normalizeMembers = useCallback(
(data) => {
const list = Array.isArray(data)
? data
: data?.members || data?.results || data?.items || data?.data || [];
useEffect(() => {
filterMembers();
}, [searchQuery, members]);
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(() => {
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 = members.length;
const totalMembers = useMemo(() => {
if (!filterValue || filterValue === 'all') {
return members.length;
}
return members.filter((member) => member.status === filterValue).length;
}, [members, filterValue]);
const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
};
const getSocialMediaLink = (url) => {
if (!url) return null;
@@ -118,165 +133,6 @@ const MembersDirectory = () => {
: <div className=' border-2 w-full border-brand-purple mb-24' />
)
}
const MemberCard = ({ member }) => (
<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 */}
{member.created_at && (
<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(member.created_at).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-[var(--neutral-100:)] to-[var(--neutral-800)]">
@@ -293,7 +149,7 @@ const MembersDirectory = () => {
LOAF Members
</h1>
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span>
<span className='text-foreground'>Number of current members in the directory: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span>
</p>
</div>
@@ -330,7 +186,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} />
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} tiers={tiers} />
))}
</div>
) : (
@@ -351,7 +207,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)]">
@@ -374,15 +230,15 @@ const MembersDirectory = () => {
</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">
<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)]" style={{ fontFamily: "'Inter', sans-serif" }}>
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedMember.first_name} {selectedMember.last_name}
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
</DialogTitle>
{selectedMember.directory_partner_name && (
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -560,8 +416,6 @@ const MembersDirectory = () => {
</DialogContent>
</Dialog>
{/* Pagination */}
{!loading && filteredMembers.length > 0 && (
<div className="mt-10 flex flex-col items-center gap-4 pb-12">

View File

@@ -128,7 +128,7 @@ export default function NewsletterArchive() {
onClick={clearFilter}
variant={selectedYear === null ? "default" : "outline"}
size="sm"
className={selectedYear === null ? "bg-brand-purple text-white" : "border-brand-purple text-brand-purple "}
className={selectedYear === null ? "bg-brand-purple hover:bg-[var(--purple-muted)] text-white" : "border-brand-lavender text-brand-lavender "}
>
All Years
</Button>
@@ -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-[var(--neutral-800)]/20 p-3 rounded-lg flex-shrink-0">
<FileText className="h-6 w-6 text-brand-purple " />
<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>
<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,3 +54,4 @@ h6 {
#ffffff 100%
);
}

View File

@@ -17,7 +17,7 @@
}
.btn-ghost {
@apply hover:bg-brand-purple bg-brand-purple/10 rounded-full disabled:opacity-50 px-6 transition-transform text-brand-purple hover:text-[var(--purple-muted)];
@apply hover:bg-brand-purple bg-brand-purple/10 rounded-full disabled:opacity-50 px-6 transition-transform text-brand-purple hover:text-background;
}
.btn-outline {
@@ -56,10 +56,10 @@
@apply h-9 w-9 rounded-full disabled:opacity-50 px-6;
}
.btn-green {
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-full px-6 ;
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-full px-6;
}
.btn-util-green {
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-xl h-12 px-6 ;
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-xl h-12 px-6;
}
.btn-util-purple {
@@ -73,24 +73,22 @@
@apply bg-brand-pink hover:bg-brand-dark-rose dark:text-[var(--lavender-100)] text-background dark:hover:text-white rounded-xl h-12 px-6 transition-transform;
}
.btn-lavender {
@apply bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2 dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender;
@apply bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2 dark:hover:bg-foreground dark:hover:text-background;
}
.btn-light-lavender {
@apply bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-lavender rounded-full px-6 transition-transform dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender ;
@apply bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-lavender rounded-full px-6 transition-transform dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender;
}
/* Badges */
.badge {
@apply px-3 py-1 rounded-full text-sm font-medium transition-transform;
}
.badge-green {
@apply bg-[var(--green-light)] text-white transition-transform;
}
/* Backgrounds */
.bg-light-lavender {
/* Backgrounds */
.bg-light-lavender {
@apply bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] text-white bg-[var(--neutral-800)] transition-transform dark:bg-brand-lavender dark:text-brand-dark-lavender;
}
}

View File

@@ -2,32 +2,6 @@
@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
========================= */
@@ -47,7 +21,7 @@
/*
==========================
Color Patch
Social Media Colors
==========================
*/
@@ -55,6 +29,50 @@
--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;
@@ -78,7 +96,7 @@
--green-muted: #66927e;
--green-soft: #6a9680;
--green-eucalyptus: #6a9a83;
--green-forest: #5e8EsOkvcwL7472374;
--green-forest: #5a7d68;
--green-fern: #6da085;
--green-mint: #6fa087;
--green-pastel: #6fa188;
@@ -243,4 +261,3 @@
--neutral-900: #f4f4ff; /* highest contrast text */
}
}

66
src/utils/logger.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* Production-safe logging utility
*
* In production (NODE_ENV=production), logs are disabled by default
* to prevent exposing sensitive information in browser console.
*
* In development, all logs are shown for debugging.
*
* Usage:
* import logger from '../utils/logger';
* logger.log('[Component]', 'message', data);
* logger.error('[Component]', 'error message', error);
* logger.warn('[Component]', 'warning message');
*/
const isDevelopment = process.env.NODE_ENV === 'development';
// Force enable logs with REACT_APP_DEBUG_LOGS=true in .env
const debugEnabled = process.env.REACT_APP_DEBUG_LOGS === 'true';
const shouldLog = isDevelopment || debugEnabled;
const logger = {
log: (...args) => {
if (shouldLog) {
console.log(...args);
}
},
error: (...args) => {
// Always log errors, but sanitize in production
if (shouldLog) {
console.error(...args);
} else {
// In production, only log error type without details
console.error('An error occurred. Enable debug logs for details.');
}
},
warn: (...args) => {
if (shouldLog) {
console.warn(...args);
}
},
info: (...args) => {
if (shouldLog) {
console.info(...args);
}
},
debug: (...args) => {
if (shouldLog) {
console.debug(...args);
}
},
// Special method for sensitive data - NEVER logs in production
sensitive: (...args) => {
if (isDevelopment) {
console.log('[SENSITIVE]', ...args);
}
}
};
export default logger;

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

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

View File

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

View File

@@ -2552,6 +2552,11 @@
"@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"