56 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
kayela
a935c0f4dd fix: correct green-forest color value for consistency 2026-01-14 11:08:16 -06:00
kayela
4ccaca192d refactor: update button and badge styles for improved theming and consistency 2026-01-14 11:07:43 -06:00
kayela
4cdccc0323 refactor: update button and badge styles for improved theming and consistency 2026-01-13 23:51:13 -06:00
kayela
21a269998d refactor: restructure styles and components for improved theming and consistency 2026-01-13 22:01:49 -06:00
kayela
e04d39fe17 Refactor color scheme in member-related pages to use brand colors instead of CSS variables for consistency and improved readability 2026-01-13 22:01:33 -06:00
kayela
30d32d8823 Theme refactor 2026-01-13 17:58:04 -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
116 changed files with 9900 additions and 3431 deletions

75
.dockerignore Normal file
View File

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

49
Dockerfile Normal file
View File

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

179
README.md
View File

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

44
nginx.conf Normal file
View File

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

View File

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

5
public/health.json Normal file
View File

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

View File

@@ -1,32 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', sans-serif;
background-color: #FFFFFF;
color: #422268;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Inter', sans-serif;
font-weight: 600;
}
.inter {
font-family: 'Inter', sans-serif;
}
.nunito-sans {
font-family: 'Nunito Sans', sans-serif;
}
.bg-purple-gradient {
background: linear-gradient(135deg, rgba(100, 76, 159, 0.2) 0%, rgba(72, 40, 110, 0.2) 100%);
}
.bg-soft-mesh {
background: radial-gradient(ellipse at top right, rgba(221, 216, 235, 0.4) 0%, #FFFFFF 50%, #FFFFFF 100%);
}

View File

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

View File

@@ -117,7 +117,7 @@ export default function AddToCalendarButton({
return ( return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}> <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild> <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" /> <Calendar className="h-4 w-4" />
Add to Calendar Add to Calendar
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
@@ -187,7 +187,7 @@ export default function AddToCalendarButton({
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Subscribe to My Events Subscribe to My Events
<div className="text-xs text-[var(--purple-lavender)] mt-0.5"> <div className="text-xs text-brand-purple mt-0.5">
Auto-syncs your RSVP'd events Auto-syncs your RSVP'd events
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
@@ -198,7 +198,7 @@ export default function AddToCalendarButton({
> >
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
Download All Events Download All Events
<div className="text-xs text-[var(--purple-lavender)] mt-0.5"> <div className="text-xs text-brand-purple mt-0.5">
One-time import of all upcoming events One-time import of all upcoming events
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
@@ -206,7 +206,7 @@ export default function AddToCalendarButton({
)} )}
{!event && !showSubscribe && ( {!event && !showSubscribe && (
<div className="px-2 py-6 text-center text-sm text-[var(--purple-lavender)]"> <div className="px-2 py-6 text-center text-sm text-brand-purple ">
No event selected No event selected
</div> </div>
)} )}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import api from '../utils/api'; import api from '../utils/api';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { import {
@@ -32,6 +33,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);
const [storageUsed, setStorageUsed] = useState(0); const [storageUsed, setStorageUsed] = useState(0);
@@ -169,10 +171,11 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
path: '/admin/bylaws', path: '/admin/bylaws',
disabled: false disabled: false
}, },
{ {
name: 'Permissions', name: 'Settings',
icon: Shield, icon: Settings,
path: '/admin/permissions', path: '/admin/settings',
disabled: false, disabled: false,
superadminOnly: true superadminOnly: true
} }
@@ -181,11 +184,15 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
// Filter nav items based on user role // Filter nav items based on user role
const filteredNavItems = navItems.filter(item => { const filteredNavItems = navItems.filter(item => {
if (item.superadminOnly && user?.role !== 'superadmin') { if (item.superadminOnly && user?.role !== 'superadmin') {
console.log('Filtering out superadmin-only item:', item.name, 'User role:', user?.role);
return false; return false;
} }
return true; 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) => { const isActive = (path) => {
if (path === '/admin') { if (path === '/admin') {
return location.pathname === '/admin'; return location.pathname === '/admin';
@@ -211,9 +218,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
className={` className={`
flex items-center gap-3 px-4 py-3 rounded-lg transition-all relative flex items-center gap-3 px-4 py-3 rounded-lg transition-all relative
${item.disabled ${item.disabled
? 'opacity-50 cursor-not-allowed text-[var(--purple-lavender)]' ? 'opacity-50 cursor-not-allowed text-brand-purple '
: active : 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' : 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/20'
} }
`} `}
@@ -243,7 +250,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* Badge when collapsed */} {/* Badge when collapsed */}
{!isOpen && item.badge > 0 && !item.disabled && ( {!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} {item.badge}
</div> </div>
)} )}
@@ -276,19 +283,16 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]"> <div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]">
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0"> <Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
<img <img
src={`${process.env.PUBLIC_URL}/loaf-logo.png`} src={getLogoUrl()}
alt="LOAF Logo" alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8' className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`} }`}
/> />
{isOpen && ( {isOpen && (
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-xl font-semibold text-primary" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-xl font-semibold text-primary dark:text-brand-light-lavender " style={{ fontFamily: "'Inter', sans-serif" }}>
Admin Admin
</h2> </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> </div>
)} )}
</Link> </Link>
@@ -367,12 +371,22 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{renderNavItem(filteredNavItems.find(item => item.name === 'Bylaws'))} {renderNavItem(filteredNavItems.find(item => item.name === 'Bylaws'))}
</div> </div>
{/* Permissions - Superadmin only (no header) */} {/* SYSTEM Section - Superadmin only */}
{user?.role === 'superadmin' && ( {user?.role === 'superadmin' && (
<div className="mt-6"> <>
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))} {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>
)} )}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Settings'))}
</div>
</>
)}
</nav> </nav>
{/* User Section */} {/* User Section */}
@@ -384,7 +398,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{user.first_name?.[0]}{user.last_name?.[0]} {user.first_name?.[0]}{user.last_name?.[0]}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-primary truncate" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-sm font-medium text-primary dark:text-brand-light-lavender truncate" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</p> </p>
<p className="text-xs text-muted-foreground capitalize truncate" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xs text-muted-foreground capitalize truncate" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -392,7 +406,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</p> </p>
</div> </div>
</div> </div>
<Link to='/profile'><Settings size={16} /> <Link className='dark:text-brand-lavender ' to='/profile'><Settings size={16} />
</Link> </Link>
</div> </div>
)} )}
@@ -406,7 +420,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
className={` className={`
flex items-center gap-3 px-4 py-3 rounded-lg w-full flex items-center gap-3 px-4 py-3 rounded-lg w-full
text-primary hover:bg-muted/20 transition-colors text-primary dark:text-brand-lavender hover:bg-muted/20 transition-colors
${!isOpen && 'justify-center'} ${!isOpen && 'justify-center'}
`} `}
> >
@@ -429,7 +443,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{isOpen ? ( {isOpen ? (
<div className="px-4 py-3 bg-[var(--lavender-500)] rounded-lg"> <div className="px-4 py-3 bg-[var(--lavender-500)] rounded-lg">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-primary">Storage Usage</span> <span className="text-sm font-medium text-primary dark:text-brand-light-lavender ">Storage Usage</span>
<span className="text-xs text-muted-foreground">{storagePercentage}%</span> <span className="text-xs text-muted-foreground">{storagePercentage}%</span>
</div> </div>
<div className="w-full bg-[var(--neutral-800)] rounded-full h-2"> <div className="w-full bg-[var(--neutral-800)] rounded-full h-2">

View File

@@ -55,7 +55,7 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Mark Attendance: {event?.title} Mark Attendance: {event?.title}
@@ -64,12 +64,12 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
{rsvps.length === 0 ? ( {rsvps.length === 0 ? (
<p className="text-center text-[var(--purple-lavender)] py-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No RSVPs yet</p> <p className="text-center text-brand-purple py-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No RSVPs yet</p>
) : ( ) : (
rsvps.map((rsvp) => ( rsvps.map((rsvp) => (
<div <div
key={rsvp.user_id} key={rsvp.user_id}
className="flex items-center gap-3 p-4 border-2 border-[var(--neutral-800)] rounded-xl hover:border-[var(--purple-lavender)] transition-colors" className="flex items-center gap-3 p-4 border-2 border-[var(--neutral-800)] rounded-xl hover:border-brand-purple transition-colors"
> >
<Checkbox <Checkbox
checked={attendance[rsvp.user_id] || false} checked={attendance[rsvp.user_id] || false}
@@ -80,7 +80,7 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
/> />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{rsvp.user_name}</p> <p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{rsvp.user_name}</p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{rsvp.user_email}</p> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{rsvp.user_email}</p>
</div> </div>
{rsvp.attended && ( {rsvp.attended && (
<span className="text-sm text-[var(--green-light)] font-medium"> <span className="text-sm text-[var(--green-light)] font-medium">
@@ -103,7 +103,7 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
<Button <Button
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
variant="outline" variant="outline"
className="flex-1 border-2 border-[var(--neutral-800)] text-[var(--purple-lavender)] hover:bg-background hover:text-[var(--purple-ink)] rounded-full" className="flex-1 border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-background hover:text-[var(--purple-ink)] rounded-full"
> >
Cancel Cancel
</Button> </Button>

View File

@@ -66,7 +66,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-background"> <DialogContent className="sm:max-w-md bg-background overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--lavender-300)]"> <div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--lavender-300)]">
@@ -76,7 +76,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
Change Password Change Password
</DialogTitle> </DialogTitle>
</div> </div>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update your password to keep your account secure. Update your password to keep your account secure.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -92,7 +92,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
value={formData.currentPassword} value={formData.currentPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Enter current password" placeholder="Enter current password"
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -106,7 +106,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
value={formData.newPassword} value={formData.newPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Enter new password (min. 6 characters)" placeholder="Enter new password (min. 6 characters)"
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -120,23 +120,22 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Re-enter new password" placeholder="Re-enter new password"
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
<DialogFooter className="mt-6"> <DialogFooter className="mt-6">
<Button <Button
type="button" type="button"
variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="rounded-full px-6" className="btn-outline mr-33"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-6 disabled:opacity-50" className=" btn-primary"
> >
{loading ? 'Changing...' : 'Change Password'} {loading ? 'Changing...' : 'Change Password'}
</Button> </Button>

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

@@ -48,8 +48,8 @@ const ConfirmationDialog = ({
}, },
info: { info: {
icon: Info, icon: Info,
iconColor: 'text-[var(--purple-lavender)]', iconColor: 'text-brand-purple ',
confirmButtonClass: 'bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-plum)] rounded-full px-6', confirmButtonClass: 'bg-brand-purple text-white hover:bg-[var(--purple-plum)] rounded-full px-6',
}, },
success: { success: {
icon: CheckCircle, icon: CheckCircle,
@@ -77,7 +77,7 @@ const ConfirmationDialog = ({
{title} {title}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription <AlertDialogDescription
className="text-[var(--purple-lavender)] text-sm leading-relaxed" className="text-brand-purple text-sm leading-relaxed"
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
> >
{description} {description}
@@ -87,7 +87,7 @@ const ConfirmationDialog = ({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="p-6 pt-4 bg-[var(--lavender-500)] flex-row gap-3 justify-end"> <AlertDialogFooter className="p-6 pt-4 bg-[var(--lavender-500)] flex-row gap-3 justify-end">
<AlertDialogCancel <AlertDialogCancel
className="border-2 border-[var(--neutral-800)] text-[var(--purple-lavender)] hover:bg-background rounded-full px-6" className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-background rounded-full px-6"
disabled={loading} disabled={loading}
> >
{cancelText} {cancelText}

View File

@@ -31,6 +31,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const getTodayDate = () => new Date().toISOString().slice(0, 10);
const handleChange = (field, value) => { const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
@@ -84,8 +85,8 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
if (payload.date_of_birth === '') { if (payload.date_of_birth === '') {
delete payload.date_of_birth; delete payload.date_of_birth;
} }
if (payload.member_since === '') { if (!payload.member_since) {
delete payload.member_since; payload.member_since = getTodayDate();
} }
await api.post('/admin/users/create', payload); await api.post('/admin/users/create', payload);
@@ -119,13 +120,13 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" /> <UserPlus className="h-6 w-6" />
Create Member Create Member
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create a new member account with direct login access. Member will be created immediately. Create a new member account with direct login access. Member will be created immediately.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -143,7 +144,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleChange('email', e.target.value)} onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="member@example.com" placeholder="member@example.com"
/> />
{errors.email && ( {errors.email && (
@@ -160,7 +161,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => handleChange('password', e.target.value)} onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Minimum 8 characters" placeholder="Minimum 8 characters"
/> />
{errors.password && ( {errors.password && (
@@ -179,8 +180,8 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
id="first_name" id="first_name"
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)} onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="John" placeholder="Jane"
/> />
{errors.first_name && ( {errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p> <p className="text-sm text-red-500">{errors.first_name}</p>
@@ -195,7 +196,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
id="last_name" id="last_name"
value={formData.last_name} value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)} onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Doe" placeholder="Doe"
/> />
{errors.last_name && ( {errors.last_name && (
@@ -214,7 +215,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)} onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="(555) 123-4567" placeholder="(555) 123-4567"
/> />
{errors.phone && ( {errors.phone && (
@@ -231,7 +232,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
id="address" id="address"
value={formData.address} value={formData.address}
onChange={(e) => handleChange('address', e.target.value)} onChange={(e) => handleChange('address', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="123 Main St" placeholder="123 Main St"
/> />
</div> </div>
@@ -244,7 +245,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
id="city" id="city"
value={formData.city} value={formData.city}
onChange={(e) => handleChange('city', e.target.value)} onChange={(e) => handleChange('city', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="San Francisco" placeholder="San Francisco"
/> />
</div> </div>
@@ -255,7 +256,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
id="state" id="state"
value={formData.state} value={formData.state}
onChange={(e) => handleChange('state', e.target.value)} onChange={(e) => handleChange('state', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="CA" placeholder="CA"
maxLength={2} maxLength={2}
/> />
@@ -267,7 +268,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
id="zipcode" id="zipcode"
value={formData.zipcode} value={formData.zipcode}
onChange={(e) => handleChange('zipcode', e.target.value)} onChange={(e) => handleChange('zipcode', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="94102" placeholder="94102"
/> />
</div> </div>
@@ -282,7 +283,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
type="date" type="date"
value={formData.date_of_birth} value={formData.date_of_birth}
onChange={(e) => handleChange('date_of_birth', e.target.value)} onChange={(e) => handleChange('date_of_birth', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -293,7 +294,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
type="date" type="date"
value={formData.member_since} value={formData.member_since}
onChange={(e) => handleChange('member_since', e.target.value)} onChange={(e) => handleChange('member_since', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
</div> </div>

View File

@@ -22,10 +22,12 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
first_name: '', first_name: '',
last_name: '', last_name: '',
phone: '', phone: '',
member_since: '',
role: 'admin' role: 'admin'
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const getTodayDate = () => new Date().toISOString().slice(0, 10);
const handleChange = (field, value) => { const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
@@ -74,7 +76,11 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
setLoading(true); setLoading(true);
try { 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'); toast.success('Staff member created successfully');
// Reset form // Reset form
@@ -84,6 +90,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
first_name: '', first_name: '',
last_name: '', last_name: '',
phone: '', phone: '',
member_since: '',
role: 'admin' role: 'admin'
}); });
@@ -99,13 +106,13 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] rounded-2xl"> <DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" /> <UserPlus className="h-6 w-6" />
Create Staff Member Create Staff Member
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create a new staff account with direct login access. User will be created immediately. Create a new staff account with direct login access. User will be created immediately.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -122,7 +129,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleChange('email', e.target.value)} onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="staff@example.com" placeholder="staff@example.com"
/> />
{errors.email && ( {errors.email && (
@@ -140,7 +147,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => handleChange('password', e.target.value)} onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Minimum 8 characters" placeholder="Minimum 8 characters"
/> />
{errors.password && ( {errors.password && (
@@ -157,8 +164,8 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
id="first_name" id="first_name"
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)} onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="John" placeholder="Jane"
/> />
{errors.first_name && ( {errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p> <p className="text-sm text-red-500">{errors.first_name}</p>
@@ -174,7 +181,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
id="last_name" id="last_name"
value={formData.last_name} value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)} onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Doe" placeholder="Doe"
/> />
{errors.last_name && ( {errors.last_name && (
@@ -192,7 +199,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)} onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="(555) 123-4567" placeholder="(555) 123-4567"
/> />
{errors.phone && ( {errors.phone && (
@@ -200,6 +207,20 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
)} )}
</div> </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 */} {/* Role */}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="role" className="text-[var(--purple-ink)]"> <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,13 +138,13 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <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> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Upload className="h-6 w-6" /> <Upload className="h-6 w-6" />
{importResult ? 'Import Results' : 'Import Members from CSV'} {importResult ? 'Import Results' : 'Import Members from CSV'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{importResult {importResult
? 'Review the import results below' ? 'Review the import results below'
: 'Upload a CSV file to bulk import members. Ensure the CSV has the required columns.'} : 'Upload a CSV file to bulk import members. Ensure the CSV has the required columns.'}
@@ -155,7 +155,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
// Upload Form // Upload Form
<div className="grid gap-6 py-4"> <div className="grid gap-6 py-4">
{/* CSV Format Instructions */} {/* CSV Format Instructions */}
<Alert className="border-[var(--purple-lavender)] bg-[var(--lavender-700)]"> <Alert className="border-brand-purple bg-[var(--lavender-700)]">
<AlertDescription className="text-sm text-[var(--purple-ink)]"> <AlertDescription className="text-sm text-[var(--purple-ink)]">
<strong>Required columns:</strong> Email, First Name, Last Name, Phone, Role <strong>Required columns:</strong> Email, First Name, Last Name, Phone, Role
<br /> <br />
@@ -168,8 +168,8 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
{/* File Upload Area */} {/* File Upload Area */}
<div <div
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors ${dragActive className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors ${dragActive
? 'border-[var(--purple-lavender)] bg-[var(--lavender-700)]' ? 'border-brand-purple bg-[var(--lavender-700)]'
: 'border-[var(--neutral-800)] hover:border-[var(--purple-lavender)] hover:bg-[var(--lavender-700)]' : 'border-[var(--neutral-800)] hover:border-brand-purple hover:bg-[var(--lavender-700)]'
}`} }`}
onDragEnter={handleDrag} onDragEnter={handleDrag}
onDragLeave={handleDrag} onDragLeave={handleDrag}
@@ -183,7 +183,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
<p className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{file.name} {file.name}
</p> </p>
<p className="text-sm text-[var(--purple-lavender)]"> <p className="text-sm text-brand-purple ">
{(file.size / 1024).toFixed(2)} KB {(file.size / 1024).toFixed(2)} KB
</p> </p>
</div> </div>
@@ -203,7 +203,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
<p className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Drag and drop your CSV file here Drag and drop your CSV file here
</p> </p>
<p className="text-sm text-[var(--purple-lavender)] mb-4">or</p> <p className="text-sm text-brand-purple mb-4">or</p>
<Label htmlFor="file-upload"> <Label htmlFor="file-upload">
<Button variant="outline" className="rounded-xl cursor-pointer" asChild> <Button variant="outline" className="rounded-xl cursor-pointer" asChild>
<span>Browse Files</span> <span>Browse Files</span>
@@ -227,7 +227,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
checked={updateExisting} checked={updateExisting}
onCheckedChange={setUpdateExisting} onCheckedChange={setUpdateExisting}
id="update-existing" id="update-existing"
className="h-5 w-5 border-2 border-[var(--purple-lavender)] data-[state=checked]:bg-[var(--purple-lavender)]" className="h-5 w-5 border-2 border-brand-purple data-[state=checked]:bg-brand-purple "
/> />
<Label htmlFor="update-existing" className="text-[var(--purple-ink)] cursor-pointer"> <Label htmlFor="update-existing" className="text-[var(--purple-ink)] cursor-pointer">
Update existing members (if email already exists) Update existing members (if email already exists)
@@ -240,7 +240,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid md:grid-cols-4 gap-4"> <div className="grid md:grid-cols-4 gap-4">
<div className="p-4 bg-background rounded-xl border border-[var(--neutral-800)] text-center"> <div className="p-4 bg-background rounded-xl border border-[var(--neutral-800)] text-center">
<p className="text-sm text-[var(--purple-lavender)] mb-1">Total Rows</p> <p className="text-sm text-brand-purple mb-1">Total Rows</p>
<p className="text-2xl font-semibold text-[var(--purple-ink)]">{importResult.total_rows}</p> <p className="text-2xl font-semibold text-[var(--purple-ink)]">{importResult.total_rows}</p>
</div> </div>
<div className="p-4 bg-green-50 rounded-xl border border-green-200 text-center"> <div className="p-4 bg-green-50 rounded-xl border border-green-200 text-center">
@@ -276,7 +276,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
{importResult.errors.map((error, idx) => ( {importResult.errors.map((error, idx) => (
<TableRow key={idx} className="hover:bg-[var(--lavender-700)]"> <TableRow key={idx} className="hover:bg-[var(--lavender-700)]">
<TableCell className="font-medium text-[var(--purple-ink)]">{error.row}</TableCell> <TableCell className="font-medium text-[var(--purple-ink)]">{error.row}</TableCell>
<TableCell className="text-[var(--purple-lavender)]">{error.email}</TableCell> <TableCell className="text-brand-purple ">{error.email}</TableCell>
<TableCell className="text-red-600 text-sm">{error.error}</TableCell> <TableCell className="text-red-600 text-sm">{error.error}</TableCell>
</TableRow> </TableRow>
))} ))}

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,13 +123,13 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] rounded-2xl"> <DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6" /> <Mail className="h-6 w-6" />
{invitationUrl ? 'Invitation Sent' : 'Invite Staff Member'} {invitationUrl ? 'Invitation Sent' : 'Invite Staff Member'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{invitationUrl {invitationUrl
? 'The invitation has been sent via email. You can also copy the link below.' ? 'The invitation has been sent via email. You can also copy the link below.'
: 'Send an email invitation to join as staff. They will set their own password.'} : 'Send an email invitation to join as staff. They will set their own password.'}
@@ -148,7 +148,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
/> />
<Button <Button
onClick={copyToClipboard} onClick={copyToClipboard}
className="rounded-xl bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white flex-shrink-0" className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white flex-shrink-0"
> >
{copied ? ( {copied ? (
<> <>
@@ -178,7 +178,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleChange('email', e.target.value)} onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="staff@example.com" placeholder="staff@example.com"
/> />
{errors.email && ( {errors.email && (
@@ -195,8 +195,8 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
id="first_name" id="first_name"
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)} onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="John" placeholder="Jane"
/> />
</div> </div>
@@ -209,7 +209,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
id="last_name" id="last_name"
value={formData.last_name} value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)} onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Doe" placeholder="Doe"
/> />
</div> </div>
@@ -224,7 +224,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)} onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="(555) 123-4567" placeholder="(555) 123-4567"
/> />
</div> </div>

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { Calendar, Users, User, BookOpen, FileText, DollarSign, Scale } from 'lu
const MemberFooter = () => { const MemberFooter = () => {
return ( return (
<footer className="bg-[var(--purple-ink)] text-white mt-auto"> <footer className="bg-brand-dark-lavender text-white mt-auto">
<div className="max-w-7xl mx-auto px-6 py-12"> <div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-4 gap-8"> <div className="grid md:grid-cols-4 gap-8">
{/* Logo & About */} {/* Logo & About */}
@@ -89,12 +89,12 @@ const MemberFooter = () => {
</Link> </Link>
</li> </li>
<li> <li>
<a href="/#contact" className="text-gray-300 hover:text-white transition-colors"> <a href="/membership/contact-us" className="text-gray-300 hover:text-white transition-colors">
Contact Us Contact Us
</a> </a>
</li> </li>
<li> <li>
<a href="/#donate" className="text-gray-300 hover:text-white transition-colors"> <a href="/membership/donate" className="text-gray-300 hover:text-white transition-colors">
Donate Donate
</a> </a>
</li> </li>
@@ -106,12 +106,12 @@ const MemberFooter = () => {
{/* Bottom Bar */} {/* Bottom Bar */}
<div className="border-t border-[var(--purple-lavender)]"> <div className="border-t border-[var(--purple-lavender)]">
<div className="max-w-7xl mx-auto px-6 py-4"> <div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-400" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-300" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div className="flex gap-6"> <div className="flex gap-6">
<a href="/#terms" className="hover:text-white transition-colors">Terms of Service</a> <a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</a>
<a href="/#privacy" className="hover:text-white transition-colors">Privacy Policy</a> <a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
</div> </div>
<p>© 2025 LOAF. All rights reserved.</p> <p>© {new Date().getFullYear()} LOAF. All rights reserved.</p>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -156,13 +156,13 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
if (!user) return null; if (!user) return null;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange} className="">
<DialogContent className="sm:max-w-[600px] bg-background rounded-2xl"> <DialogContent className="sm:max-w-[600px] bg-background rounded-2xl overflow-y-auto max-h-[90vh] p-6">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Activate Manual Payment Activate Manual Payment
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Record offline payment for {user.first_name} {user.last_name} ({user.email}) Record offline payment for {user.first_name} {user.last_name} ({user.email})
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -203,7 +203,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
</SelectContent> </SelectContent>
</Select> </Select>
{selectedPlan && ( {selectedPlan && (
<p className="text-xs text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedPlan.description || `${selectedPlan.billing_cycle} subscription`} {selectedPlan.description || `${selectedPlan.billing_cycle} subscription`}
</p> </p>
)} )}
@@ -222,11 +222,11 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
placeholder="Enter amount" placeholder="Enter amount"
value={formData.amount} value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })} onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
required required
/> />
{selectedPlan && ( {selectedPlan && (
<p className="text-xs text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)} Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)}
</p> </p>
)} )}
@@ -263,13 +263,13 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
Payment Date Payment Date
</Label> </Label>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[var(--purple-lavender)]" /> <Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input <Input
id="payment_date" id="payment_date"
type="date" type="date"
value={formData.payment_date} value={formData.payment_date}
onChange={(e) => setFormData({ ...formData, payment_date: e.target.value })} onChange={(e) => setFormData({ ...formData, payment_date: e.target.value })}
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
required required
/> />
</div> </div>
@@ -308,7 +308,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
onChange={(e) => setUseCustomPeriod(e.target.checked)} onChange={(e) => setUseCustomPeriod(e.target.checked)}
className="rounded border-[var(--neutral-800)]" className="rounded border-[var(--neutral-800)]"
/> />
<Label htmlFor="use_custom_period" className="text-sm text-[var(--purple-lavender)] font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <Label htmlFor="use_custom_period" className="text-sm text-brand-purple font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Use custom dates instead of plan's billing cycle Use custom dates instead of plan's billing cycle
</Label> </Label>
</div> </div>
@@ -324,7 +324,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
type="date" type="date"
value={formData.custom_period_start} value={formData.custom_period_start}
onChange={(e) => setFormData({ ...formData, custom_period_start: e.target.value })} onChange={(e) => setFormData({ ...formData, custom_period_start: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
required={useCustomPeriod} required={useCustomPeriod}
/> />
</div> </div>
@@ -337,14 +337,14 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
type="date" type="date"
value={formData.custom_period_end} value={formData.custom_period_end}
onChange={(e) => setFormData({ ...formData, custom_period_end: e.target.value })} onChange={(e) => setFormData({ ...formData, custom_period_end: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
required={useCustomPeriod} required={useCustomPeriod}
/> />
</div> </div>
</div> </div>
) : ( ) : (
selectedPlan && ( selectedPlan && (
<div className="text-sm text-[var(--purple-lavender)] bg-[var(--lavender-300)] p-3 rounded-lg space-y-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="text-sm text-brand-purple bg-[var(--lavender-300)] p-3 rounded-lg space-y-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedPlan.custom_cycle_enabled ? ( {selectedPlan.custom_cycle_enabled ? (
<> <>
<p> <p>
@@ -386,7 +386,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
placeholder="Additional notes about the payment..." placeholder="Additional notes about the payment..."
value={formData.notes} value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })} onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] min-h-[100px]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
/> />
</div> </div>

View File

@@ -73,7 +73,7 @@ const PendingInvitationsTable = () => {
const getRoleBadge = (role) => { const getRoleBadge = (role) => {
const config = { const config = {
superadmin: { label: 'Superadmin', className: 'bg-[var(--purple-lavender)] text-white' }, superadmin: { label: 'Superadmin', className: 'bg-brand-purple text-white' },
admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' }, admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' },
member: { label: 'Member', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' } member: { label: 'Member', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' }
}; };
@@ -111,7 +111,7 @@ const PendingInvitationsTable = () => {
if (loading) { if (loading) {
return ( return (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading invitations... Loading invitations...
</p> </p>
</div> </div>
@@ -125,7 +125,7 @@ const PendingInvitationsTable = () => {
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Pending Invitations No Pending Invitations
</h3> </h3>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
All invitations have been accepted or expired All invitations have been accepted or expired
</p> </p>
</Card> </Card>
@@ -152,19 +152,19 @@ const PendingInvitationsTable = () => {
<TableCell className="font-medium text-[var(--purple-ink)]"> <TableCell className="font-medium text-[var(--purple-ink)]">
{invitation.email} {invitation.email}
</TableCell> </TableCell>
<TableCell className="text-[var(--purple-lavender)]"> <TableCell className="text-brand-purple ">
{invitation.first_name && invitation.last_name {invitation.first_name && invitation.last_name
? `${invitation.first_name} ${invitation.last_name}` ? `${invitation.first_name} ${invitation.last_name}`
: '-'} : '-'}
</TableCell> </TableCell>
<TableCell>{getRoleBadge(invitation.role)}</TableCell> <TableCell>{getRoleBadge(invitation.role)}</TableCell>
<TableCell className="text-[var(--purple-lavender)]"> <TableCell className="text-brand-purple ">
{new Date(invitation.invited_at).toLocaleDateString()} {new Date(invitation.invited_at).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className={`h-4 w-4 ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500' : 'text-[var(--purple-lavender)]'}`} /> <Clock className={`h-4 w-4 ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500' : 'text-brand-purple '}`} />
<span className={`text-sm ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500 font-semibold' : 'text-[var(--purple-lavender)]'}`}> <span className={`text-sm ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500 font-semibold' : 'text-brand-purple '}`}>
{formatDate(invitation.expires_at)} {formatDate(invitation.expires_at)}
</span> </span>
</div> </div>
@@ -211,7 +211,7 @@ const PendingInvitationsTable = () => {
<AlertDialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <AlertDialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Revoke Invitation Revoke Invitation
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <AlertDialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Are you sure you want to revoke the invitation for{' '} Are you sure you want to revoke the invitation for{' '}
<span className="font-semibold">{revokeDialog.invitation?.email}</span>? <span className="font-semibold">{revokeDialog.invitation?.email}</span>?
This action cannot be undone. This action cannot be undone.

View File

@@ -159,12 +159,12 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{plan ? 'Edit Plan' : 'Create New Plan'} {plan ? 'Edit Plan' : 'Create New Plan'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{plan ? 'Update plan details below' : 'Enter plan details to create a new subscription plan'} {plan ? 'Update plan details below' : 'Enter plan details to create a new subscription plan'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -216,7 +216,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
required required
className="mt-2" className="mt-2"
/> />
<p className="text-xs text-[var(--purple-lavender)] mt-1">Minimum $30</p> <p className="text-xs text-brand-purple mt-1">Minimum $30</p>
</div> </div>
<div> <div>
@@ -232,7 +232,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
required required
className="mt-2" className="mt-2"
/> />
<p className="text-xs text-[var(--purple-lavender)] mt-1">Pre-filled amount</p> <p className="text-xs text-brand-purple mt-1">Pre-filled amount</p>
</div> </div>
</div> </div>
@@ -240,7 +240,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<div> <div>
<Label htmlFor="allow_donation">Allow Donations</Label> <Label htmlFor="allow_donation">Allow Donations</Label>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Members can pay more than minimum Members can pay more than minimum
</p> </p>
</div> </div>
@@ -252,7 +252,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
onChange={(e) => setFormData({ ...formData, allow_donation: e.target.checked })} onChange={(e) => setFormData({ ...formData, allow_donation: e.target.checked })}
className="sr-only peer" className="sr-only peer"
/> />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[var(--purple-lavender)]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--green-light)]"></div> <div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-purple /20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--green-light)]"></div>
</label> </label>
</div> </div>
</div> </div>
@@ -283,7 +283,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
<h3 className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Custom Billing Period Custom Billing Period
</h3> </h3>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Set recurring date range (e.g., Jan 1 - Dec 31 for calendar year) Set recurring date range (e.g., Jan 1 - Dec 31 for calendar year)
</p> </p>
@@ -361,7 +361,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label htmlFor="active">Active Status</Label> <Label htmlFor="active">Active Status</Label>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Inactive plans won't appear for new subscriptions Inactive plans won't appear for new subscriptions
</p> </p>
</div> </div>
@@ -373,7 +373,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
onChange={(e) => setFormData({ ...formData, active: e.target.checked })} onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
className="sr-only peer" className="sr-only peer"
/> />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[var(--purple-lavender)]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--green-light)]"></div> <div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-purple /20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--green-light)]"></div>
</label> </label>
</div> </div>

View File

@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useThemeConfig } from '../context/ThemeConfigContext';
const PublicFooter = () => { const PublicFooter = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return ( return (
<> <>
@@ -60,7 +62,7 @@ const PublicFooter = () => {
</Link> </Link>
</nav> </nav>
<p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
© 2025 LOAF. All Rights Reserved. © {new Date().getFullYear()} LOAF. All Rights Reserved.
</p> </p>
<p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-3 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-3 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Designed and Managed by{' '} Designed and Managed by{' '}

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import { ChevronDown, Menu, X } from 'lucide-react'; import { ChevronDown, Menu, X } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@@ -12,6 +13,7 @@ import {
const PublicNavbar = () => { const PublicNavbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -30,8 +32,8 @@ const PublicNavbar = () => {
return location.pathname.startsWith('/about'); return location.pathname.startsWith('/about');
}; };
// LOAF logo (local) // Get logo URL from theme config (with fallback to default)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const loafLogo = getLogoUrl();
const handleAuthAction = () => { const handleAuthAction = () => {
if (user) { if (user) {
@@ -75,8 +77,24 @@ const PublicNavbar = () => {
<div className='sticky top-0 inset-x-0 z-50'> <div className='sticky top-0 inset-x-0 z-50'>
<header className="bg-gradient-to-r flex-wrap from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-[20px] py-[10px] flex md:justify-end justify-between items-center gap-4 sm:gap-6"> <header className="bg-gradient-to-r flex-wrap from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-[20px] py-[10px] flex md:justify-end justify-between items-center gap-4 sm:gap-6">
<div className='flex gap-4 sm:gap-6'> <div className='flex gap-4 sm:gap-6 items-center'>
{user && (
<span
className="text-white text-base font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Welcome, {user.first_name}
</span>
)}
{(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link
to="/admin"
className="text-white text-base font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Dashboard
</Link>
)}
<button <button
onClick={handleAuthAction} onClick={handleAuthAction}
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer" className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
@@ -105,7 +123,7 @@ const PublicNavbar = () => {
</header> </header>
{/* Main Header - Navigation */} {/* Main Header - Navigation */}
<header className=" bg-[var(--purple-lavender)] px-[20px] py-2 flex justify-between items-center"> <header className=" bg-brand-purple px-[20px] py-2 flex justify-between items-center">
<Link to="/"> <Link to="/">
<img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" /> <img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
</Link> </Link>
@@ -165,7 +183,7 @@ const PublicNavbar = () => {
className={getDesktopLinkClasses(user ? "/dashboard" : "/become-a-member")} className={getDesktopLinkClasses(user ? "/dashboard" : "/become-a-member")}
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
> >
{user ? 'Dashboard' : 'Become a Member'} {user ? 'My Profile' : 'Become a Member'}
</Link> </Link>
{!user && ( {!user && (
<Link <Link
@@ -176,7 +194,71 @@ const PublicNavbar = () => {
Members Only Members Only
</Link> </Link>
)} )}
{user && (
<>
<Link <Link
to="/events"
className={getDesktopLinkClasses('/events')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Events
</Link>
<Link
to="/members/calendar"
className={getDesktopLinkClasses('/members/calendar')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Calendar
</Link>
<Link
to="/members/directory"
className={getDesktopLinkClasses('/members/directory')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Directory
</Link>
<Link
to="/members/gallery"
className={getDesktopLinkClasses('/members/gallery')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Gallery
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`${location.pathname.startsWith('/members/newsletters') || location.pathname.startsWith('/members/financials') || location.pathname.startsWith('/members/bylaws')
? "text-[var(--orange-light)] hover:text-[var(--orange-coral)]"
: "text-white hover:opacity-80"} text-[17.5px] font-medium transition-all flex items-center gap-1 bg-transparent border-none cursor-pointer px-3 py-1 rounded-md`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Documents
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
<DropdownMenuItem asChild>
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Newsletters
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/members/financials" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Financials
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/members/bylaws" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Bylaws
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{/* <Link
to="/resources" to="/resources"
className={getDesktopLinkClasses('/resources')} className={getDesktopLinkClasses('/resources')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
@@ -189,7 +271,7 @@ const PublicNavbar = () => {
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
> >
Contact Us Contact Us
</Link> </Link> */}
</nav> </nav>
</header> </header>
@@ -204,7 +286,7 @@ const PublicNavbar = () => {
/> />
{/* Drawer */} {/* Drawer */}
<div className="fixed right-0 top-0 h-full w-[280px] bg-[var(--purple-lavender)] 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 */} {/* Header */}
<div className="flex justify-between items-center p-6 border-b border-[var(--purple-deep)]"> <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" }}> <span className="text-white text-lg font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -219,6 +301,18 @@ const PublicNavbar = () => {
</button> </button>
</div> </div>
{/* User Info */}
{user && (
<div className="px-6 py-4 border-b border-[var(--purple-deep)]">
<p className="text-white text-sm opacity-90" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Welcome,
</p>
<p className="text-white font-semibold text-base" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.first_name} {user.last_name}
</p>
</div>
)}
{/* Navigation Links */} {/* Navigation Links */}
<nav className="flex flex-col p-6 space-y-4"> <nav className="flex flex-col p-6 space-y-4">
<Link <Link
@@ -270,7 +364,7 @@ const PublicNavbar = () => {
className={getMobileLinkClasses(user ? "/dashboard" : "/become-a-member")} className={getMobileLinkClasses(user ? "/dashboard" : "/become-a-member")}
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
> >
{user ? 'Dashboard' : 'Become a Member'} {user ? 'My Profile' : 'Become a Member'}
</Link> </Link>
{!user && ( {!user && (
@@ -284,6 +378,80 @@ const PublicNavbar = () => {
</Link> </Link>
)} )}
{user && (
<>
<Link
to="/events"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileLinkClasses('/events')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Events
</Link>
<Link
to="/members/calendar"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileLinkClasses('/members/calendar')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Calendar
</Link>
<Link
to="/members/directory"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileLinkClasses('/members/directory')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Directory
</Link>
<Link
to="/members/gallery"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileLinkClasses('/members/gallery')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Gallery
</Link>
{/* Documents Section */}
<div className="space-y-2">
<p
className={`text-base font-semibold px-4 py-2 rounded-md ${location.pathname.startsWith('/members/newsletters') || location.pathname.startsWith('/members/financials') || location.pathname.startsWith('/members/bylaws') ? 'text-[var(--orange-light)]' : 'text-white'}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Documents
</p>
<Link
to="/members/newsletters"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileSubLinkClasses('/members/newsletters')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Newsletters
</Link>
<Link
to="/members/financials"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileSubLinkClasses('/members/financials')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Financials
</Link>
<Link
to="/members/bylaws"
onClick={() => setIsMobileMenuOpen(false)}
className={getMobileSubLinkClasses('/members/bylaws')}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Bylaws
</Link>
</div>
</>
)}
<Link <Link
to="/resources" to="/resources"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
@@ -304,6 +472,16 @@ const PublicNavbar = () => {
{/* Auth Actions */} {/* Auth Actions */}
<div className="pt-4 border-t border-[var(--purple-deep)] space-y-2"> <div className="pt-4 border-t border-[var(--purple-deep)] space-y-2">
{(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link
to="/admin"
onClick={() => setIsMobileMenuOpen(false)}
className="block text-white text-base font-medium hover:bg-[var(--purple-deep)] px-4 py-3 rounded-md transition-colors"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Dashboard
</Link>
)}
<button <button
onClick={() => { onClick={() => {
handleAuthAction(); handleAuthAction();

View File

@@ -41,17 +41,17 @@ export default function RejectionDialog({ open, onOpenChange, onConfirm, user, l
Reject Application Reject Application
</DialogTitle> </DialogTitle>
</div> </div>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You are about to reject <strong>{user?.first_name} {user?.last_name}</strong>'s membership application. You are about to reject <strong>{user?.first_name} {user?.last_name}</strong>'s membership application.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="bg-[var(--lavender-400)] border border-[var(--neutral-800)] rounded-lg p-4"> <div className="bg-[var(--lavender-400)] border border-[var(--neutral-800)] rounded-lg p-4">
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<strong>Applicant:</strong> {user?.email} <strong>Applicant:</strong> {user?.email}
</p> </p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<strong>Status:</strong> {user?.status} <strong>Status:</strong> {user?.status}
</p> </p>
</div> </div>
@@ -74,7 +74,7 @@ export default function RejectionDialog({ open, onOpenChange, onConfirm, user, l
{error && ( {error && (
<p className="text-sm text-red-500">{error}</p> <p className="text-sm text-red-500">{error}</p>
)} )}
<p className="text-xs text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
The applicant will receive an email with this reason. The applicant will receive an email with this reason.
</p> </p>
</div> </div>
@@ -85,7 +85,7 @@ export default function RejectionDialog({ open, onOpenChange, onConfirm, user, l
type="button" type="button"
onClick={handleClose} onClick={handleClose}
variant="outline" variant="outline"
className="border-2 border-[var(--neutral-800)] text-[var(--purple-lavender)] hover:bg-[var(--lavender-300)] rounded-full px-6" className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
disabled={loading} disabled={loading}
> >
<X className="h-4 w-4 mr-2" /> <X className="h-4 w-4 mr-2" />

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

@@ -371,14 +371,14 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Upload WordPress CSV Export</h3> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Upload WordPress CSV Export</h3>
<p className="text-sm text-[var(--purple-lavender)]"> <p className="text-sm text-brand-purple ">
Select the WordPress user export CSV file. The file will be analyzed for data quality issues. Select the WordPress user export CSV file. The file will be analyzed for data quality issues.
</p> </p>
</div> </div>
<Card className="p-6 border-2 border-dashed border-[var(--neutral-800)] bg-[var(--lavender-400)]"> <Card className="p-6 border-2 border-dashed border-[var(--neutral-800)] bg-[var(--lavender-400)]">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Upload className="h-12 w-12 text-[var(--purple-lavender)]" /> <Upload className="h-12 w-12 text-brand-purple " />
<div className="text-center"> <div className="text-center">
<Input <Input
type="file" type="file"
@@ -387,7 +387,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
className="max-w-xs" className="max-w-xs"
/> />
{uploadedFile && ( {uploadedFile && (
<p className="text-sm text-[var(--purple-lavender)] mt-2"> <p className="text-sm text-brand-purple mt-2">
Selected: {uploadedFile.name} Selected: {uploadedFile.name}
</p> </p>
)} )}
@@ -399,7 +399,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<Button <Button
onClick={handleUpload} onClick={handleUpload}
disabled={uploading} disabled={uploading}
className="w-full bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)]" className="w-full bg-brand-purple hover:bg-[var(--purple-ink)]"
> >
{uploading ? ( {uploading ? (
<> <>
@@ -466,7 +466,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Field Mapping</h3> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Field Mapping</h3>
<p className="text-sm text-[var(--purple-lavender)]"> <p className="text-sm text-brand-purple ">
WordPress fields have been automatically mapped to LOAF platform fields. WordPress fields have been automatically mapped to LOAF platform fields.
</p> </p>
</div> </div>
@@ -538,7 +538,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Review & Adjust User Status</h3> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Review & Adjust User Status</h3>
<p className="text-sm text-[var(--purple-lavender)]"> <p className="text-sm text-brand-purple ">
Review suggested status mappings and override as needed before import. Review suggested status mappings and override as needed before import.
</p> </p>
</div> </div>
@@ -550,7 +550,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
checked={selectedRows.size === previewData.length && previewData.length > 0} checked={selectedRows.size === previewData.length && previewData.length > 0}
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
/> />
<span className="text-sm text-[var(--purple-lavender)] font-medium"> <span className="text-sm text-brand-purple font-medium">
{selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'} {selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'}
</span> </span>
{selectedRows.size > 0 && ( {selectedRows.size > 0 && (
@@ -572,7 +572,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
{/* Data table */} {/* Data table */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-[var(--purple-lavender)]" /> <Loader2 className="h-8 w-8 animate-spin text-brand-purple " />
</div> </div>
) : ( ) : (
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
@@ -651,7 +651,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-[var(--purple-lavender)]"> <p className="text-sm text-brand-purple ">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -690,22 +690,22 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Preview</h3> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Preview</h3>
<p className="text-sm text-[var(--purple-lavender)]"> <p className="text-sm text-brand-purple ">
Review the final import settings before execution. Review the final import settings before execution.
</p> </p>
</div> </div>
<div className="grid md:grid-cols-3 gap-4"> <div className="grid md:grid-cols-3 gap-4">
<Card className="p-6"> <Card className="p-6">
<p className="text-sm text-[var(--purple-lavender)]">Total Users</p> <p className="text-sm text-brand-purple ">Total Users</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.total_rows}</p> <p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.total_rows}</p>
</Card> </Card>
<Card className="p-6"> <Card className="p-6">
<p className="text-sm text-[var(--purple-lavender)]">Status Overrides</p> <p className="text-sm text-brand-purple ">Status Overrides</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{overrideCount}</p> <p className="text-3xl font-semibold text-[var(--purple-ink)]">{overrideCount}</p>
</Card> </Card>
<Card className="p-6"> <Card className="p-6">
<p className="text-sm text-[var(--purple-lavender)]">Expected Imports</p> <p className="text-sm text-brand-purple ">Expected Imports</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.valid_rows}</p> <p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.valid_rows}</p>
</Card> </Card>
</div> </div>
@@ -715,15 +715,15 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" /> <CheckCircle className="h-5 w-5 text-green-600" />
<span className="text-sm text-[var(--purple-lavender)]">Send password reset emails to all imported users</span> <span className="text-sm text-brand-purple ">Send password reset emails to all imported users</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" /> <CheckCircle className="h-5 w-5 text-green-600" />
<span className="text-sm text-[var(--purple-lavender)]">Skip rows with errors and continue import</span> <span className="text-sm text-brand-purple ">Skip rows with errors and continue import</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" /> <CheckCircle className="h-5 w-5 text-green-600" />
<span className="text-sm text-[var(--purple-lavender)]">Full rollback capability available after import</span> <span className="text-sm text-brand-purple ">Full rollback capability available after import</span>
</div> </div>
</div> </div>
</Card> </Card>
@@ -751,7 +751,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
{importing ? 'Import in Progress...' : 'Ready to Import'} {importing ? 'Import in Progress...' : 'Ready to Import'}
</h3> </h3>
<p className="text-sm text-[var(--purple-lavender)]"> <p className="text-sm text-brand-purple ">
{importing {importing
? 'Please wait while users are imported. This may take a few minutes.' ? 'Please wait while users are imported. This may take a few minutes.'
: 'Click "Start Import" to begin importing users.'} : 'Click "Start Import" to begin importing users.'}
@@ -761,7 +761,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
{importing && ( {importing && (
<div className="space-y-4"> <div className="space-y-4">
<Progress value={importProgress} className="w-full" /> <Progress value={importProgress} className="w-full" />
<p className="text-center text-sm text-[var(--purple-lavender)]"> <p className="text-center text-sm text-brand-purple ">
{importProgress.toFixed(1)}% complete {importProgress.toFixed(1)}% complete
</p> </p>
</div> </div>
@@ -770,7 +770,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
{!importing && !importResults && ( {!importing && !importResults && (
<Button <Button
onClick={handleExecuteImport} onClick={handleExecuteImport}
className="w-full bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] py-6 text-lg" className="w-full bg-brand-purple hover:bg-[var(--purple-ink)] py-6 text-lg"
> >
<Play className="mr-2 h-5 w-5" /> <Play className="mr-2 h-5 w-5" />
Start Import Start Import
@@ -787,7 +787,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Complete</h3> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Complete</h3>
<p className="text-sm text-[var(--purple-lavender)]"> <p className="text-sm text-brand-purple ">
Review the import results and download error reports if needed. Review the import results and download error reports if needed.
</p> </p>
</div> </div>
@@ -854,7 +854,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
Confirm Rollback Confirm Rollback
</DialogTitle> </DialogTitle>
</div> </div>
<DialogDescription className="text-[var(--purple-lavender)]"> <DialogDescription className="text-brand-purple ">
This will permanently delete{' '} This will permanently delete{' '}
<strong>{importResults?.successful_rows} users</strong> that were imported. <strong>{importResults?.successful_rows} users</strong> that were imported.
This action cannot be undone. This action cannot be undone.
@@ -896,12 +896,12 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]"> <DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]">
WordPress Import Wizard WordPress Import Wizard
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]"> <DialogDescription className="text-brand-purple ">
Import WordPress users with interactive status review and full rollback capability Import WordPress users with interactive status review and full rollback capability
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -919,7 +919,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<div <div
className={` className={`
w-10 h-10 rounded-full flex items-center justify-center w-10 h-10 rounded-full flex items-center justify-center
${isCurrent ? 'bg-[var(--purple-lavender)] text-white' : ''} ${isCurrent ? 'bg-brand-purple text-white' : ''}
${isCompleted ? 'bg-green-600 text-white' : ''} ${isCompleted ? 'bg-green-600 text-white' : ''}
${!isCurrent && !isCompleted ? 'bg-gray-200 text-gray-600' : ''} ${!isCurrent && !isCompleted ? 'bg-gray-200 text-gray-600' : ''}
`} `}
@@ -962,7 +962,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={!canProceed()} disabled={!canProceed()}
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)]" className="bg-brand-purple hover:bg-[var(--purple-ink)]"
> >
Next Next
<ChevronRight className="h-4 w-4 ml-2" /> <ChevronRight className="h-4 w-4 ml-2" />
@@ -975,7 +975,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
onOpenChange(false); onOpenChange(false);
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();
}} }}
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)]" className="bg-brand-purple hover:bg-[var(--purple-ink)]"
> >
Close Close
</Button> </Button>

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

@@ -40,7 +40,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
required required
value={formData.first_name} value={formData.first_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="first-name-input" data-testid="first-name-input"
/> />
</div> </div>
@@ -52,7 +52,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
required required
value={formData.last_name} value={formData.last_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="last-name-input" data-testid="last-name-input"
/> />
</div> </div>
@@ -69,7 +69,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
required required
value={formData.phone} value={formData.phone}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="phone-input" data-testid="phone-input"
/> />
</div> </div>
@@ -82,7 +82,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
required required
value={formData.date_of_birth} value={formData.date_of_birth}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="dob-input" data-testid="dob-input"
/> />
</div> </div>
@@ -112,7 +112,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
required required
value={formData.city} value={formData.city}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="city-input" data-testid="city-input"
/> />
</div> </div>
@@ -124,7 +124,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
required required
value={formData.state} value={formData.state}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="state-input" data-testid="state-input"
/> />
</div> </div>
@@ -136,7 +136,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
required required
value={formData.zipcode} value={formData.zipcode}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="zipcode-input" data-testid="zipcode-input"
/> />
</div> </div>
@@ -179,7 +179,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
name="partner_first_name" name="partner_first_name"
value={formData.partner_first_name} value={formData.partner_first_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="partner-first-name-input" data-testid="partner-first-name-input"
/> />
</div> </div>
@@ -190,7 +190,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
name="partner_last_name" name="partner_last_name"
value={formData.partner_last_name} value={formData.partner_last_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="partner-last-name-input" data-testid="partner-last-name-input"
/> />
</div> </div>

View File

@@ -36,7 +36,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Newsletter Publication Preferences * Newsletter Publication Preferences *
</h2> </h2>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Please check what information may be published in LOAF Newsletter Please check what information may be published in LOAF Newsletter
</p> </p>
@@ -110,10 +110,10 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
value={formData.referred_by_member_name} value={formData.referred_by_member_name}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Enter member name or email" placeholder="Enter member name or email"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="referral-input" data-testid="referral-input"
/> />
<p className="text-sm text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
If referred by a current member, you may skip the event attendance requirement. If referred by a current member, you may skip the event attendance requirement.
</p> </p>
</div> </div>
@@ -124,7 +124,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests (Optional) Volunteer Interests (Optional)
</h2> </h2>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
I may at some time be interested in volunteering with LOAF in the following ways (training is provided) I may at some time be interested in volunteering with LOAF in the following ways (training is provided)
</p> </p>
@@ -158,7 +158,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
I am requesting for scholarship I am requesting for scholarship
</Label> </Label>
</div> </div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Scholarship information is kept confidential Scholarship information is kept confidential
</p> </p>
@@ -174,7 +174,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Tell us why you're requesting a scholarship..." placeholder="Tell us why you're requesting a scholarship..."
rows={4} rows={4}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
)} )}

View File

@@ -27,7 +27,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
Members Directory Members Directory
</h2> </h2>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Would you like to be displayed on our private members directory? (optional and you can change the answer later) Would you like to be displayed on our private members directory? (optional and you can change the answer later)
</p> </p>
@@ -38,7 +38,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
p-4 rounded-xl border-2 cursor-pointer transition-all p-4 rounded-xl border-2 cursor-pointer transition-all
${formData.show_in_directory ${formData.show_in_directory
? 'border-[var(--orange-light)] bg-[var(--orange-light)]/5' ? 'border-[var(--orange-light)] bg-[var(--orange-light)]/5'
: 'border-[var(--neutral-800)] hover:border-[var(--purple-lavender)]' : 'border-[var(--neutral-800)] hover:border-brand-purple '
} }
`} `}
onClick={() => setFormData(prev => ({ ...prev, show_in_directory: true }))} onClick={() => setFormData(prev => ({ ...prev, show_in_directory: true }))}
@@ -63,7 +63,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
p-4 rounded-xl border-2 cursor-pointer transition-all p-4 rounded-xl border-2 cursor-pointer transition-all
${!formData.show_in_directory ${!formData.show_in_directory
? 'border-[var(--orange-light)] bg-[var(--orange-light)]/5' ? 'border-[var(--orange-light)] bg-[var(--orange-light)]/5'
: 'border-[var(--neutral-800)] hover:border-[var(--purple-lavender)]' : 'border-[var(--neutral-800)] hover:border-brand-purple '
} }
`} `}
onClick={() => setFormData(prev => ({ ...prev, show_in_directory: false }))} onClick={() => setFormData(prev => ({ ...prev, show_in_directory: false }))}
@@ -88,7 +88,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
{/* Conditional Directory Fields */} {/* Conditional Directory Fields */}
{formData.show_in_directory && ( {formData.show_in_directory && (
<div className="space-y-4 mt-6 p-6 bg-background rounded-xl border border-[var(--neutral-800)]"> <div className="space-y-4 mt-6 p-6 bg-background rounded-xl border border-[var(--neutral-800)]">
<p className="text-[var(--purple-lavender)] text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Below, choose what information you would like include in the Members Only Directory. Below, choose what information you would like include in the Members Only Directory.
(If you ever want to update this information, remember the Directory Section and Account Section are separate) (If you ever want to update this information, remember the Directory Section and Account Section are separate)
</p> </p>
@@ -101,7 +101,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
type="email" type="email"
value={formData.directory_email} value={formData.directory_email}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -114,7 +114,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Tell other members about yourself..." placeholder="Tell other members about yourself..."
rows={4} rows={4}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -125,7 +125,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
name="directory_address" name="directory_address"
value={formData.directory_address} value={formData.directory_address}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -137,7 +137,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
type="tel" type="tel"
value={formData.directory_phone} value={formData.directory_phone}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -149,7 +149,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
type="date" type="date"
value={formData.directory_dob} value={formData.directory_dob}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -162,7 +162,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
name="directory_partner_name" name="directory_partner_name"
value={formData.directory_partner_name} value={formData.directory_partner_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
</div> </div>

View File

@@ -11,7 +11,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
Account Credentials Account Credentials
</h2> </h2>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your email is also your username that you can use to login. Your email is also your username that you can use to login.
Please note you can only login after your application is validated. Please note you can only login after your application is validated.
</p> </p>
@@ -28,7 +28,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
value={formData.email} value={formData.email}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="your.email@example.com" placeholder="your.email@example.com"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="email-input" data-testid="email-input"
/> />
</div> </div>
@@ -43,10 +43,10 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
value={formData.password} value={formData.password}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="At least 6 characters" placeholder="At least 6 characters"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="password-input" data-testid="password-input"
/> />
<p className="text-sm text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Must be at least 6 characters long Must be at least 6 characters long
</p> </p>
</div> </div>
@@ -60,7 +60,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Re-enter your password" placeholder="Re-enter your password"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="confirm-password-input" data-testid="confirm-password-input"
/> />
{formData.confirmPassword && formData.password !== formData.confirmPassword && ( {formData.confirmPassword && formData.password !== formData.confirmPassword && (
@@ -79,7 +79,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
name="accepts_tos" name="accepts_tos"
checked={formData.accepts_tos || false} checked={formData.accepts_tos || false}
onChange={handleInputChange} onChange={handleInputChange}
className="mt-1 w-4 h-4 text-[var(--purple-lavender)] border-gray-300 rounded focus:ring-[var(--purple-lavender)]" className="mt-1 w-4 h-4 text-brand-purple border-gray-300 rounded focus:ring-brand-purple "
required required
data-testid="tos-checkbox" data-testid="tos-checkbox"
/> />
@@ -89,7 +89,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
href="/become-a-member/terms-of-service" href="/become-a-member/terms-of-service"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[var(--purple-lavender)] hover:text-[var(--purple-ink)] font-semibold underline" className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
> >
Terms of Service Terms of Service
</a> </a>
@@ -98,7 +98,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
href="become-a-member/privacy-policy" href="become-a-member/privacy-policy"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[var(--purple-lavender)] hover:text-[var(--purple-ink)] font-semibold underline" className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
> >
Privacy Policy Privacy Policy
</a> </a>

View File

@@ -23,14 +23,14 @@ const RegistrationStepIndicator = ({ currentStep, totalSteps = 4 }) => {
? 'bg-[var(--orange-light)] text-white scale-110 shadow-lg' ? 'bg-[var(--orange-light)] text-white scale-110 shadow-lg'
: currentStep > step.number : currentStep > step.number
? 'bg-[var(--green-light)] text-white' ? 'bg-[var(--green-light)] text-white'
: 'bg-[var(--neutral-800)] text-[var(--purple-lavender)]' : 'bg-[var(--neutral-800)] text-brand-purple '
} }
`}> `}>
{currentStep > step.number ? '✓' : step.number} {currentStep > step.number ? '✓' : step.number}
</div> </div>
<span className={` <span className={`
text-sm mt-2 font-medium transition-colors text-sm mt-2 font-medium transition-colors
${currentStep === step.number ? 'text-[var(--orange-light)]' : 'text-[var(--purple-lavender)]'} ${currentStep === step.number ? 'text-[var(--orange-light)]' : 'text-brand-purple '}
`} style={{ fontFamily: "'Nunito Sans', sans-serif" }}> `} style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{step.title} {step.title}
</span> </span>
@@ -52,7 +52,7 @@ const RegistrationStepIndicator = ({ currentStep, totalSteps = 4 }) => {
</div> </div>
{/* Step Counter */} {/* Step Counter */}
<p className="text-center text-[var(--purple-lavender)] mt-6 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-center text-brand-purple mt-6 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Step <span className="font-semibold text-[var(--orange-light)]">{currentStep}</span> of {totalSteps} Step <span className="font-semibold text-[var(--orange-light)]">{currentStep}</span> of {totalSteps}
</p> </p>
</div> </div>

View File

@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
@@ -9,26 +9,38 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", "border-transparent bg-primary text-primary-foreground shadow ",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground", outline: "text-foreground",
green:
"border-transparent bg-[var(--green-forest)] text-white hover:bg-[var(--green-fern)]",
orange:
"border-transparent bg-orange-500 text-white hover:bg-orange-500/80",
orange2:
"border-transparent bg-orange-100 text-orange-700 hover:bg-orange-100/80",
pink: "border-transparent bg-[var(--pink-500)] text-white hover:bg-[var(--pink-500)]/80",
red: "border-transparent bg-red-100 text-red-700 hover:bg-red-100/80",
red2: "border-transparent bg-red-500 text-white hover:bg-red-500/80",
gray: "border-transparent bg-gray-200 text-gray-700 hover:bg-gray-200/80",
gray2: "border-transparent bg-gray-400 text-white hover:bg-gray-400/80",
gray3:
"border-transparent bg-gray-300 text-gray-600 hover:bg-gray-300/80",
purple: "bg-light-lavender",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} }
) );
function Badge({ function Badge({ className, variant, ...props }) {
className, return (
variant, <div className={cn(badgeVariants({ variant }), className)} {...props} />
...props );
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@@ -1,48 +1,46 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva("btn", {
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: { variants: {
variant: { variant: {
default: default: "btn-primary",
"bg-primary text-primary-foreground shadow hover:bg-primary/90", secondary: "btn-secondary",
destructive: ghost: "btn-ghost",
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: "btn-outline",
outline: "outline-destructive": "btn-outline-destructive",
"border border-input shadow-sm hover:bg-accent hover:text-accent-foreground", accent: "btn-accent",
secondary: destructive: "btn-destructive",
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", link: "btn-link",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "btn-md",
sm: "h-8 rounded-md px-3 text-xs", sm: "btn-sm",
lg: "h-10 rounded-md px-8", lg: "btn-lg",
icon: "h-9 w-9", icon: "btn-icon",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} });
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { const Button = React.forwardRef(
const Comp = asChild ? Slot : "button" ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size }), className)}
ref={ref} ref={ref}
{...props} /> {...props}
/>
); );
}) }
Button.displayName = "Button" );
Button.displayName = "Button";
export { Button, buttonVariants } export { Button, buttonVariants };

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) => ( const Card = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} className={cn(
{...props} /> "rounded-xl border bg-card text-card-foreground shadow",
)) className,
Card.displayName = "Card" )}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} /> {...props}
)) />
CardHeader.displayName = "CardHeader" ));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)} className={cn("font-semibold leading-none tracking-tight", className)}
{...props} /> {...props}
)) />
CardTitle.displayName = "CardTitle" ));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} /> {...props}
)) />
CardDescription.displayName = "CardDescription" ));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef(({ className, ...props }, ref) => ( const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)) ));
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} /> {...props}
)) />
CardFooter.displayName = "CardFooter" ));
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) => ( const CommandList = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.List <CommandPrimitive.List
ref={ref} 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} /> {...props} />
)) ))

View File

@@ -47,7 +47,7 @@ const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} /> {...props} />

View File

@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, .
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className className
)} )}
@@ -50,7 +50,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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]", "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 className
)} )}
@@ -63,7 +63,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref)
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className className
)} )}

View File

@@ -7,7 +7,7 @@ const Input = React.forwardRef(({ className, type, ...props }, ref) => {
<input <input
type={type} type={type}
className={cn( 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 className
)} )}
ref={ref} ref={ref}

View File

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

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

View File

@@ -21,7 +21,7 @@ const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap hover:bg-[var(--lavender-300)] border-2 border-[var(--purple-lavender)] rounded-2xl px-3 py-1 text-[var(--purple-lavender)] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background data-[state=active]:border-foreground data-[state=active]:shadow", "inline-flex items-center justify-center whitespace-nowrap hover:bg-[var(--lavender-300)] border-2 border-brand-purple rounded-2xl px-3 py-1 text-brand-purple text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background data-[state=active]:border-foreground data-[state=active]:shadow dark:data-[state=active]:bg-brand-light-lavender dark:data-[state=active]:text-background dark:border-brand-light-lavender dark:text-brand-light-lavender",
className className
)} )}
{...props} {...props}

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 React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import api from '../utils/api';
import logger from '../utils/logger';
const AuthContext = createContext(); const AuthContext = createContext();
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin; const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
// Log environment on module load for debugging // 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_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME, REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
API_URL: API_URL API_URL: API_URL
@@ -55,31 +57,31 @@ export const AuthProvider = ({ children }) => {
}); });
setPermissions(response.data.permissions || []); setPermissions(response.data.permissions || []);
} catch (error) { } catch (error) {
console.error('Failed to fetch permissions:', error); logger.error('Failed to fetch permissions:', error);
setPermissions([]); setPermissions([]);
} }
}; };
const login = async (email, password) => { const login = async (email, password) => {
try { try {
console.log('[AuthContext] Starting login request...', { logger.log('[AuthContext] Starting login request...', {
API_URL: API_URL, API_URL: API_URL,
envBackendUrl: process.env.REACT_APP_BACKEND_URL, envBackendUrl: process.env.REACT_APP_BACKEND_URL,
fullUrl: `${API_URL}/api/auth/login` fullUrl: `${API_URL}/api/auth/login`
}); });
const response = await axios.post( // Use api instance for retry logic
`${API_URL}/api/auth/login`, const response = await api.post(
'/auth/login',
{ email, password }, { email, password },
{ {
timeout: 30000, // 30 second timeout
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
} }
); );
console.log('[AuthContext] Login response received:', { logger.log('[AuthContext] Login response received:', {
status: response.status, status: response.status,
hasToken: !!response.data?.access_token, hasToken: !!response.data?.access_token,
hasUser: !!response.data?.user hasUser: !!response.data?.user
@@ -87,39 +89,46 @@ export const AuthProvider = ({ children }) => {
const { access_token, user: userData } = response.data; const { access_token, user: userData } = response.data;
// Store token first if (!access_token || !userData) {
localStorage.setItem('token', access_token); throw new Error('Invalid response from server - missing token or user data');
console.log('[AuthContext] Token stored in localStorage'); }
// 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); setToken(access_token);
setUser(userData); setUser(userData);
console.log('[AuthContext] User state updated:', { logger.log('[AuthContext] User state updated:', {
email: userData.email, email: userData.email,
role: userData.role role: userData.role
}); });
// Fetch user permissions (don't let this fail the login) // Fetch permissions immediately and WAIT for it (but don't fail login if it fails)
// Use setTimeout to defer permission fetching slightly
setTimeout(async () => {
try { try {
console.log('[AuthContext] Fetching permissions...'); logger.log('[AuthContext] Fetching permissions...');
await fetchPermissions(access_token); await fetchPermissions(access_token);
console.log('[AuthContext] Permissions fetched successfully'); logger.log('[AuthContext] Permissions fetched successfully');
} catch (error) { } catch (permError) {
console.error('[AuthContext] Failed to fetch permissions (non-critical):', { logger.error('[AuthContext] Failed to fetch permissions (non-critical):', {
message: error.message, message: permError.message,
response: error.response?.data, response: permError.response?.data,
status: error.response?.status status: permError.response?.status
}); });
// Don't throw - permissions can be fetched later if needed // Set empty permissions array so hasPermission doesn't break
setPermissions([]);
// Don't throw - login succeeded even if permissions failed
} }
}, 100); // Small delay to ensure state is settled
return userData; return userData;
} catch (error) { } catch (error) {
// Enhanced error logging // Enhanced error logging
console.error('[AuthContext] Login failed:', { logger.error('[AuthContext] Login failed:', {
message: error.message, message: error.message,
response: error.response?.data, response: error.response?.data,
status: error.response?.status, 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 // Re-throw to let Login component handle the error
throw error; throw error;
} }
@@ -160,7 +175,7 @@ export const AuthProvider = ({ children }) => {
setUser(response.data); setUser(response.data);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Failed to refresh user:', error); logger.error('Failed to refresh user:', error);
// If token expired, logout // If token expired, logout
if (error.response?.status === 401) { if (error.response?.status === 401) {
logout(); 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,233 +1,8 @@
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"); @import "./styles/App.css";
@import "./styles/theme.css";
@import url("https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap"); @import "./styles/components.css";
@import "./styles/base.css";
@tailwind base; @import "./styles/utilities.css";
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 280 47% 27%;
--card: 0 0% 100%;
--card-foreground: 280 47% 27%;
--popover: 0 0% 100%;
--popover-foreground: 280 47% 27%;
--primary: 280 47% 27%;
--primary-foreground: 0 0% 100%;
--secondary: 268 33% 89%;
--secondary-foreground: 280 47% 27%;
--muted: 268 43% 95%;
--muted-foreground: 268 35% 47%;
--accent: 17 100% 73%;
--accent-foreground: 280 47% 27%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 268 33% 89%;
--input: 268 33% 89%;
--ring: 268 35% 47%;
--chart-1: 268 36% 46%;
--chart-2: 17 100% 73%;
--chart-3: 268 33% 89%;
--chart-4: 280 44% 29%;
--chart-5: 268 35% 47%;
--radius: 0.5rem;
/* =========================
Brand Colors
========================= */
--brand-dark-lavender: 267 47% 29%;
--brand-purple: 256 35% 47%;
--brand-lavender: 262 46% 80%;
--brand-light-lavender: 256 32% 88%;
--brand-white: 0 0% 100%;
--brand-dark-orange: 13 100% 42%;
--brand-orange: 24 86% 55%;
--brand-light-orange: 24 100% 67%;
--brand-pink: 324 55% 60%;
--dusty-pink: 323 39% 52%;
--dark-rose: 324 98% 32%;
/*
==========================
Color Patch
==========================
*/
--blue-linkedin: #0a66c2;
--blue-facebook: #1877f2;
--blue-twitter: #1da1f2;
--purple-ink: #422268;
--purple-deep: #48286e;
--purple-muted: #533a82;
--purple-plum: #553d8a;
--purple-soft: #5a4290;
--purple-amethyst: #644c9f;
--purple-lilac: #664ea2;
--purple-lavender: #664fa3;
--purple-electric: #865edf;
--slate-dark: #3d405b;
--slate-muted: #6b708d;
--slate-600: #6b7280;
--slate-400: #9ca3af;
--slate-dark: #3d405b;
--slate-muted: #6b708d;
--slate-600: #6b7280;
--slate-400: #9ca3af;
--green-success: #4caf50;
--green-sage: #5a8f72;
--green-muted: #66927e;
--green-soft: #6a9680;
--green-eucalyptus: #6a9a83;
--green-fern: #6da085;
--green-mint: #6fa087;
--green-pastel: #6fa188;
--green-light: #81b29a;
--green-bg: #e8f5e9;
--orange-rust: #d16b54;
--orange-soft: #e07a5f;
--orange-peach: #e88a63;
--orange-sand: #e88d66;
--orange-apricot: #ff8c5a;
--orange-coral: #ff8c64;
--orange-light: #ff9e77;
--orange-500: #ea580c;
--orange-400: #fb923c;
--gold-soft: #e8bf7a;
--gold-warm: #f2cc8f;
--gold-soft: #e8bf7a;
--gold-warm: #f2cc8f;
--red-instagram: #e4405f;
--red-soft: #ffebee;
--lavender-100: #e8e0f5;
--lavender-200: #eeebf4;
--lavender-300: #f1eef9;
--lavender-400: #f9f5ff;
--lavender-500: #f8f7fb;
--lavender-600: #f9f7fc;
--lavender-700: #f9f8fb;
--lavender-800: #eaedf4;
--neutral-50: #fafafa;
--neutral-100: #f9fafb;
--neutral-200: #fdfcf8;
--neutral-300: #eae0d5;
--neutral-400: #c4bed8;
--neutral-500: #c5b4e3;
--neutral-600: #c5bfd9;
--neutral-700: #dcd7ea;
--neutral-800: #ddd8eb;
--neutral-900: #ffffff;
}
.dark {
--background: var(--brand-dark-lavender);
--foreground: var(--brand-light-lavender);
--card: var(--brand-purple);
--card-foreground: var(--brand-light-lavender);
--popover: var(--brand-purple);
--popover-foreground: var(--brand-light-lavender);
--primary: var(--brand-light-lavender);
--primary-foreground: var(--brand-dark-lavender);
--secondary: var(--brand-purple);
--secondary-foreground: var(--brand-light-lavender);
--muted: var(--brand-purple);
--muted-foreground: var(--brand-light-lavender);
--accent: var(--brand-light-lavender);
--accent-foreground: var(--brand-light-lavender);
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--brand-light-lavender);
--border: var(--brand-purple);
--input: var(--brand-purple);
--ring: var(--brand-light-lavender);
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer base {
[data-debug-wrapper="true"] {
display: contents !important;
}
[data-debug-wrapper="true"] > * {
margin-left: inherit;
margin-right: inherit;
margin-top: inherit;
margin-bottom: inherit;
padding-left: inherit;
padding-right: inherit;
padding-top: inherit;
padding-bottom: inherit;
column-gap: inherit;
row-gap: inherit;
gap: inherit;
border-left-width: inherit;
border-right-width: inherit;
border-top-width: inherit;
border-bottom-width: inherit;
border-left-style: inherit;
border-right-style: inherit;
border-top-style: inherit;
border-bottom-style: inherit;
border-left-color: inherit;
border-right-color: inherit;
border-top-color: inherit;
border-bottom-color: inherit;
}
}
@layer utilities {
@supports selector(::-webkit-scrollbar) {
.scrollbar-dashboard::-webkit-scrollbar {
width: 2px;
}
.scrollbar-dashboard::-webkit-scrollbar-thumb {
background-color: #ddd8eb;
border-radius: 9999px;
}
.scrollbar-x-dashboard::-webkit-scrollbar:horizontal {
height: 2px;
}
.scrollbar-x-dashboard::-webkit-scrollbar-thumb:horizontal {
background-color: #ddd8eb;
border-radius: 9999px;
}
.hide-scrollbar-x::-webkit-scrollbar:horizontal {
height: 0px;
}
}
}
/* /*
========================= =========================
End of File End of File

View File

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

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { Menu } from 'lucide-react';
import AdminSidebar from '../components/AdminSidebar'; import AdminSidebar from '../components/AdminSidebar';
import { UsersProvider } from '../context/UsersContext';
const AdminLayout = ({ children }) => { const AdminLayout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -46,6 +48,7 @@ const AdminLayout = ({ children }) => {
}; };
return ( return (
<UsersProvider>
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}> <div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
{/* Sidebar */} {/* Sidebar */}
<AdminSidebar <AdminSidebar
@@ -63,12 +66,30 @@ const AdminLayout = ({ children }) => {
)} )}
{/* Main Content Area */} {/* Main Content Area */}
<main className="flex-1 overflow-y-auto"> <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"> <div className="max-w-7xl mx-auto px-6 py-8">
{children} {children}
</div> </div>
</main> </main>
</div> </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

@@ -163,7 +163,7 @@ const AcceptInvitation = () => {
const getRoleBadge = (role) => { const getRoleBadge = (role) => {
const config = { const config = {
superadmin: { label: 'Superadmin', className: 'bg-[var(--purple-lavender)] text-white' }, superadmin: { label: 'Superadmin', className: 'bg-brand-purple text-white' },
admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' }, admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' },
member: { label: 'Member', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' } member: { label: 'Member', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' }
}; };
@@ -181,7 +181,7 @@ const AcceptInvitation = () => {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-md p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center"> <Card className="w-full max-w-md p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center">
<Loader2 className="h-12 w-12 text-[var(--purple-lavender)] mx-auto mb-4 animate-spin" /> <Loader2 className="h-12 w-12 text-brand-purple mx-auto mb-4 animate-spin" />
<p className="text-lg text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-lg text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Verifying your invitation... Verifying your invitation...
</p> </p>
@@ -198,12 +198,12 @@ const AcceptInvitation = () => {
<h1 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Invalid Invitation Invalid Invitation
</h1> </h1>
<p className="text-[var(--purple-lavender)] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{error} {error}
</p> </p>
<Button <Button
onClick={() => navigate('/login')} onClick={() => navigate('/login')}
className="rounded-xl bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white" className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white"
> >
Go to Login Go to Login
</Button> </Button>
@@ -229,7 +229,7 @@ const AcceptInvitation = () => {
<h1 className="text-4xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Welcome to LOAF! 🎉 Welcome to LOAF! 🎉
</h1> </h1>
<p className="text-xl text-[var(--purple-lavender)] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xl text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your account has been created successfully. Your account has been created successfully.
</p> </p>
@@ -237,7 +237,7 @@ const AcceptInvitation = () => {
<div className="mb-8 p-6 bg-gradient-to-r from-[var(--neutral-800)] to-[var(--lavender-700)] rounded-xl"> <div className="mb-8 p-6 bg-gradient-to-r from-[var(--neutral-800)] to-[var(--lavender-700)] rounded-xl">
<div className="grid md:grid-cols-2 gap-4 text-left"> <div className="grid md:grid-cols-2 gap-4 text-left">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Name Name
</p> </p>
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -245,7 +245,7 @@ const AcceptInvitation = () => {
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Email Email
</p> </p>
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -253,13 +253,13 @@ const AcceptInvitation = () => {
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Role Role
</p> </p>
<div>{getRoleBadge(successUser?.role)}</div> <div>{getRoleBadge(successUser?.role)}</div>
</div> </div>
<div> <div>
<p className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Status Status
</p> </p>
<Badge className="bg-[var(--green-light)] text-white px-4 py-2 rounded-full text-sm"> <Badge className="bg-[var(--green-light)] text-white px-4 py-2 rounded-full text-sm">
@@ -295,14 +295,14 @@ const AcceptInvitation = () => {
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-[var(--purple-lavender)] to-[var(--purple-ink)] flex items-center justify-center"> <div className="h-16 w-16 rounded-full bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] flex items-center justify-center">
<Mail className="h-8 w-8 text-white" /> <Mail className="h-8 w-8 text-white" />
</div> </div>
</div> </div>
<h1 className="text-3xl md:text-4xl font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl md:text-4xl font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Welcome to LOAF! Welcome to LOAF!
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Complete your profile to accept the invitation Complete your profile to accept the invitation
</p> </p>
</div> </div>
@@ -311,7 +311,7 @@ const AcceptInvitation = () => {
<div className="mb-8 p-6 bg-gradient-to-r from-[var(--neutral-800)] to-[var(--lavender-700)] rounded-xl"> <div className="mb-8 p-6 bg-gradient-to-r from-[var(--neutral-800)] to-[var(--lavender-700)] rounded-xl">
<div className="grid md:grid-cols-2 gap-4 text-sm"> <div className="grid md:grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Email Address Email Address
</p> </p>
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -319,13 +319,13 @@ const AcceptInvitation = () => {
</p> </p>
</div> </div>
<div> <div>
<p className="text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Role Role
</p> </p>
<div>{getRoleBadge(invitation?.role)}</div> <div>{getRoleBadge(invitation?.role)}</div>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<p className="text-[var(--purple-lavender)] mb-1 flex items-center gap-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-1 flex items-center gap-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Invitation Expires Invitation Expires
</p> </p>
@@ -350,7 +350,7 @@ const AcceptInvitation = () => {
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => handleChange('password', e.target.value)} onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Minimum 8 characters" placeholder="Minimum 8 characters"
/> />
{formErrors.password && ( {formErrors.password && (
@@ -367,7 +367,7 @@ const AcceptInvitation = () => {
type="password" type="password"
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)} onChange={(e) => handleChange('confirmPassword', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Re-enter password" placeholder="Re-enter password"
/> />
{formErrors.confirmPassword && ( {formErrors.confirmPassword && (
@@ -386,8 +386,8 @@ const AcceptInvitation = () => {
id="first_name" id="first_name"
value={formData.first_name} value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)} onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="John" placeholder="Jane"
/> />
{formErrors.first_name && ( {formErrors.first_name && (
<p className="text-sm text-red-500">{formErrors.first_name}</p> <p className="text-sm text-red-500">{formErrors.first_name}</p>
@@ -402,7 +402,7 @@ const AcceptInvitation = () => {
id="last_name" id="last_name"
value={formData.last_name} value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)} onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Doe" placeholder="Doe"
/> />
{formErrors.last_name && ( {formErrors.last_name && (
@@ -421,7 +421,7 @@ const AcceptInvitation = () => {
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)} onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="(555) 123-4567" placeholder="(555) 123-4567"
/> />
{formErrors.phone && ( {formErrors.phone && (
@@ -445,7 +445,7 @@ const AcceptInvitation = () => {
id="address" id="address"
value={formData.address} value={formData.address}
onChange={(e) => handleChange('address', e.target.value)} onChange={(e) => handleChange('address', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="123 Main St" placeholder="123 Main St"
/> />
</div> </div>
@@ -458,7 +458,7 @@ const AcceptInvitation = () => {
id="city" id="city"
value={formData.city} value={formData.city}
onChange={(e) => handleChange('city', e.target.value)} onChange={(e) => handleChange('city', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="San Francisco" placeholder="San Francisco"
/> />
</div> </div>
@@ -469,7 +469,7 @@ const AcceptInvitation = () => {
id="state" id="state"
value={formData.state} value={formData.state}
onChange={(e) => handleChange('state', e.target.value)} onChange={(e) => handleChange('state', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="CA" placeholder="CA"
maxLength={2} maxLength={2}
/> />
@@ -481,7 +481,7 @@ const AcceptInvitation = () => {
id="zipcode" id="zipcode"
value={formData.zipcode} value={formData.zipcode}
onChange={(e) => handleChange('zipcode', e.target.value)} onChange={(e) => handleChange('zipcode', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="94102" placeholder="94102"
/> />
</div> </div>
@@ -495,7 +495,7 @@ const AcceptInvitation = () => {
type="date" type="date"
value={formData.date_of_birth} value={formData.date_of_birth}
onChange={(e) => handleChange('date_of_birth', e.target.value)} onChange={(e) => handleChange('date_of_birth', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
</> </>
@@ -526,11 +526,11 @@ const AcceptInvitation = () => {
{/* Footer Note */} {/* Footer Note */}
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Already have an account?{' '} Already have an account?{' '}
<button <button
onClick={() => navigate('/login')} onClick={() => navigate('/login')}
className="text-[var(--purple-lavender)] hover:text-[var(--purple-ink)] font-semibold underline" className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
> >
Sign in instead Sign in instead
</button> </button>

View File

@@ -95,7 +95,7 @@ const ChangePasswordRequired = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Password Change Required Password Change Required
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your password was reset by an administrator. Please create a new password to continue. Your password was reset by an administrator. Please create a new password to continue.
</p> </p>
</div> </div>
@@ -111,7 +111,7 @@ const ChangePasswordRequired = () => {
value={formData.currentPassword} value={formData.currentPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Enter temporary password" placeholder="Enter temporary password"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -125,7 +125,7 @@ const ChangePasswordRequired = () => {
value={formData.newPassword} value={formData.newPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Enter new password (min. 6 characters)" placeholder="Enter new password (min. 6 characters)"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -139,14 +139,14 @@ const ChangePasswordRequired = () => {
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Re-enter new password" placeholder="Re-enter new password"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
<div className="bg-[var(--lavender-300)] border-l-4 border-[var(--purple-lavender)] p-4 rounded-lg"> <div className="bg-[var(--lavender-300)] border-l-4 border-brand-purple p-4 rounded-lg">
<div className="flex items-start"> <div className="flex items-start">
<Lock className="h-5 w-5 text-[var(--purple-lavender)] mr-2 mt-0.5 flex-shrink-0" /> <Lock className="h-5 w-5 text-brand-purple mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="font-medium text-[var(--purple-ink)] mb-1">Password Requirements:</p> <p className="font-medium text-[var(--purple-ink)] mb-1">Password Requirements:</p>
<ul className="list-disc list-inside space-y-1"> <ul className="list-disc list-inside space-y-1">
<li>At least 6 characters long</li> <li>At least 6 characters long</li>
@@ -169,7 +169,7 @@ const ChangePasswordRequired = () => {
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className="text-[var(--purple-lavender)] hover:text-[var(--orange-light)] text-sm underline" className="text-brand-purple hover:text-[var(--orange-light)] text-sm underline"
> >
Logout instead Logout instead
</button> </button>

View File

@@ -7,8 +7,11 @@ import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge'; import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar'; import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter'; import MemberFooter from '../components/MemberFooter';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale } from 'lucide-react'; import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import TransactionHistory from '../components/TransactionHistory';
import MemberBadge from '@/components/MemberBadge';
import useMemberTiers from '../hooks/use-member-tiers'
const Dashboard = () => { const Dashboard = () => {
const { user, resendVerificationEmail, refreshUser } = useAuth(); const { user, resendVerificationEmail, refreshUser } = useAuth();
@@ -17,10 +20,16 @@ const Dashboard = () => {
const [resendLoading, setResendLoading] = useState(false); const [resendLoading, setResendLoading] = useState(false);
const [eventActivity, setEventActivity] = useState(null); const [eventActivity, setEventActivity] = useState(null);
const [activityLoading, setActivityLoading] = useState(true); const [activityLoading, setActivityLoading] = useState(true);
const [transactionsLoading, setTransactionsLoading] = useState(true);
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [activeTransactionTab, setActiveTransactionTab] = useState('all');
const joinedDate = user?.member_since || user?.created_at;
const { tiers, loading: tiersLoading } = useMemberTiers();
useEffect(() => { useEffect(() => {
fetchUpcomingEvents(); fetchUpcomingEvents();
fetchEventActivity(); fetchEventActivity();
fetchTransactions();
}, []); }, []);
const fetchUpcomingEvents = async () => { 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 () => { const handleResendVerification = async () => {
setResendLoading(true); setResendLoading(true);
try { try {
@@ -70,6 +92,7 @@ const Dashboard = () => {
} }
}; };
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const statusConfig = { const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
@@ -110,6 +133,8 @@ const Dashboard = () => {
return messages[status] || ''; return messages[status] || '';
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Navbar /> <Navbar />
@@ -120,21 +145,21 @@ const Dashboard = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Welcome Back, {user?.first_name}! Welcome Back, {user?.first_name}!
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Here's an overview of your membership status and upcoming events. Here's an overview of your membership status and upcoming events.
</p> </p>
</div> </div>
{/* Email Verification Alert */} {/* Email Verification Alert */}
{user && !user.email_verified && ( {user && !user.email_verified && (
<Card className="p-6 bg-[var(--lavender-300)] border-2 border-[var(--purple-lavender)] mb-8"> <Card className="p-6 bg-[var(--lavender-300)] border-2 border-brand-purple mb-8">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<AlertCircle className="h-6 w-6 text-[var(--purple-lavender)] flex-shrink-0 mt-1" /> <AlertCircle className="h-6 w-6 text-brand-purple flex-shrink-0 mt-1" />
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Verify Your Email Address Verify Your Email Address
</h3> </h3>
<p className="text-[var(--purple-lavender)] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Please verify your email address to complete your registration. Please verify your email address to complete your registration.
Check your inbox for the verification link. Check your inbox for the verification link.
</p> </p>
@@ -142,7 +167,7 @@ const Dashboard = () => {
onClick={handleResendVerification} onClick={handleResendVerification}
disabled={resendLoading} disabled={resendLoading}
variant="outline" variant="outline"
className="border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--purple-lavender)] hover:text-white" className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white"
> >
<Mail className="h-4 w-4 mr-2" /> <Mail className="h-4 w-4 mr-2" />
{resendLoading ? 'Sending...' : 'Resend Verification Email'} {resendLoading ? 'Sending...' : 'Resend Verification Email'}
@@ -162,54 +187,78 @@ const Dashboard = () => {
<div className="mb-4"> <div className="mb-4">
{getStatusBadge(user?.status)} {getStatusBadge(user?.status)}
</div> </div>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{getStatusMessage(user?.status)} {getStatusMessage(user?.status)}
</p> </p>
</div> </div>
<Link to="/profile"> <Link to="/profile">
<Button <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" data-testid="view-profile-button"
> >
<User className="h-4 w-4 mr-2" /> <User className="h-4 w-4 mr-2" />
View Profile Edit Profile
</Button> </Button>
</Link> </Link>
</div> </div>
</Card> </Card>
{/* Grid Layout */} {/* Grid Layout */}
<div className="grid lg:grid-cols-3 gap-8"> <div className="grid lg:grid-cols-2 gap-8">
{/* Quick Stats */} {/* Quick Stats */}
<div className='space-y-8'>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-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" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Quick Info Quick Info
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{/* member date and badge */}
<div className='flex justify-between'>
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</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" }}>{user?.email}</p>
</div>
<div>
<p className="text-sm text-[var(--purple-lavender)]" 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-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'} {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
</p> </p>
</div> </div>
{!tiersLoading && (
<div className='lg:mr-10'>
<MemberBadge memberSince={joinedDate} tiers={tiers} />
</div>
)}
</div>
{/* email */}
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
</div>
{/* role */}
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Membership Info
</h3>
<div className="space-y-4">
{!user.subscription_end_date && !user.subscription_end_date && (
<div>No subscriptions yet</div>
)}
{user?.subscription_start_date && user?.subscription_end_date && ( {user?.subscription_start_date && user?.subscription_end_date && (
<> <>
<div className="pt-4 border-t border-[var(--neutral-800)]"> <div className="pt-4 border-t border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p> <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" }}> <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()} {new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p> <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" }}> <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 {Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
</p> </p>
@@ -218,45 +267,43 @@ const Dashboard = () => {
)} )}
</div> </div>
</Card> </Card>
</div>
{/* Upcoming Events */} {/* Upcoming Events */}
<Card className="lg:col-span-2 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card"> <Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Upcoming Events My Event Activity
</h3> </h3>
<Link to="/events"> <Link to="/events">
<Button <Button className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender px-6">
variant="ghost" <Calendar className="h-4 w-4 mr-2" />
className="text-[var(--orange-light)] hover:text-[var(--purple-lavender)]" Browse Events
data-testid="view-all-events-button"
>
View All
</Button> </Button>
</Link> </Link>
</div> </div>
{loading ? ( {loading ? (
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
) : events.length > 0 ? ( ) : events.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{events.map((event) => ( {events.map((event) => (
<Link to={`/events/${event.id}`} key={event.id}> <Link to={`/events/${event.id}`} key={event.id}>
<div <div
className="p-4 border border-[var(--neutral-800)] rounded-xl hover:border-[var(--purple-lavender)] hover:shadow-md transition-all cursor-pointer" className="p-4 border border-[var(--neutral-800)] rounded-xl hover:border-brand-purple hover:shadow-md transition-all cursor-pointer"
data-testid={`event-card-${event.id}`} data-testid={`event-card-${event.id}`}
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg"> <div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-[var(--purple-lavender)]" /> <Calendar className="h-6 w-6 text-brand-purple " />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4> <h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <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).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p> </p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -266,8 +313,8 @@ const Dashboard = () => {
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" /> <Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
<p className="text-[var(--purple-lavender)] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No upcoming events at the moment.</p> <p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No upcoming events at the moment.</p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Check back later for new events!</p> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Check back later for new events!</p>
</div> </div>
)} )}
</Card> </Card>
@@ -280,7 +327,7 @@ const Dashboard = () => {
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Application Under Review Application Under Review
</h3> </h3>
<p className="text-[var(--purple-lavender)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your membership application is being reviewed by our admin team. You'll be notified once validated! Your membership application is being reviewed by our admin team. You'll be notified once validated!
</p> </p>
</div> </div>
@@ -289,15 +336,15 @@ const Dashboard = () => {
{/* Payment Prompt for payment_pending status */} {/* Payment Prompt for payment_pending status */}
{user?.status === 'payment_pending' && ( {user?.status === 'payment_pending' && (
<Card className="mt-8 p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border-2 border-[var(--purple-lavender)]"> <Card className="mt-8 p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border-2 border-brand-purple ">
<div className="text-center"> <div className="text-center">
<div className="mb-4"> <div className="mb-4">
<AlertCircle className="h-16 w-16 text-[var(--purple-lavender)] mx-auto" /> <AlertCircle className="h-16 w-16 text-brand-purple mx-auto" />
</div> </div>
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Complete Your Payment Complete Your Payment
</h3> </h3>
<p className="text-[var(--purple-lavender)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Great news! Your membership application has been validated. Complete your payment to activate your membership and gain full access to all member benefits. Great news! Your membership application has been validated. Complete your payment to activate your membership and gain full access to all member benefits.
</p> </p>
<Link to="/plans"> <Link to="/plans">
@@ -313,155 +360,18 @@ const Dashboard = () => {
</Card> </Card>
)} )}
{/* Event Activity Section */} {/* Transaction History Section */}
<div className="mt-12"> <div className="mt-8">
<div className="flex justify-between items-center mb-6"> <TransactionHistory
<h2 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> subscriptions={transactions.subscriptions}
My Event Activity donations={transactions.donations}
</h2> totalSubscriptionCents={transactions.total_subscription_amount_cents}
totalDonationCents={transactions.total_donation_amount_cents}
loading={transactionsLoading}
isAdmin={false}
/>
</div> </div>
{activityLoading ? (
<p className="text-[var(--purple-lavender)]" 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-[var(--purple-lavender)]" />
</div>
<div>
<p className="text-sm text-[var(--purple-lavender)]" 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-[var(--purple-lavender)]" 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-[var(--purple-lavender)] 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-[var(--purple-lavender)] 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-[var(--purple-lavender)]" 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-[var(--purple-lavender)] 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-[var(--purple-lavender)]" 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-[var(--purple-lavender)] 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-[var(--purple-lavender)] 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-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Failed to load event activity. Please try refreshing the page.
</p>
</div>
</Card>
)}
</div>
</div> </div>
<MemberFooter /> <MemberFooter />
</div> </div>

View File

@@ -37,7 +37,7 @@ const DonationSuccess = () => {
{/* Message */} {/* Message */}
<div className="space-y-4 mb-8"> <div className="space-y-4 mb-8">
<p className="text-xl text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xl text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your generous contribution helps support our community and continue our mission. Your generous contribution helps support our community and continue our mission.
</p> </p>
@@ -48,12 +48,12 @@ const DonationSuccess = () => {
Your Support Makes a Difference Your Support Makes a Difference
</span> </span>
</div> </div>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
A receipt for your donation has been sent to your email address. A receipt for your donation has been sent to your email address.
</p> </p>
</div> </div>
<p className="text-base text-[var(--purple-lavender)] pt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-base text-brand-purple pt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
We deeply appreciate your support and commitment to LOAF's mission of building a vibrant, inclusive community. We deeply appreciate your support and commitment to LOAF's mission of building a vibrant, inclusive community.
</p> </p>
</div> </div>
@@ -62,7 +62,7 @@ const DonationSuccess = () => {
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button <Button
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-ink)] rounded-full px-8 py-6 text-lg font-medium shadow-lg" className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-8 py-6 text-lg font-medium shadow-lg"
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
Return to Home Return to Home
@@ -70,7 +70,7 @@ const DonationSuccess = () => {
<Button <Button
onClick={() => navigate('/donate')} onClick={() => navigate('/donate')}
variant="outline" variant="outline"
className="border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--neutral-800)]/20 rounded-full px-8 py-6 text-lg font-medium" className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--neutral-800)]/20 rounded-full px-8 py-6 text-lg font-medium"
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
Make Another Donation Make Another Donation
@@ -80,12 +80,12 @@ const DonationSuccess = () => {
{/* Additional Info */} {/* Additional Info */}
<div className="mt-12 text-center"> <div className="mt-12 text-center">
<p className="text-sm text-[var(--purple-lavender)] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Have questions about your donation? Have questions about your donation?
</p> </p>
<a <a
href="mailto:support@loaf.org" href="mailto:support@loaf.org"
className="text-[var(--orange-light)] hover:text-[var(--purple-lavender)] font-medium transition-colors" className="text-[var(--orange-light)] hover:text-brand-purple font-medium transition-colors"
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
Contact us at support@loaf.org Contact us at support@loaf.org

View File

@@ -51,7 +51,7 @@ const EventDetails = () => {
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Navbar /> <Navbar />
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event...</p> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event...</p>
</div> </div>
</div> </div>
); );
@@ -68,7 +68,7 @@ const EventDetails = () => {
<div className="max-w-4xl mx-auto px-6 py-12"> <div className="max-w-4xl mx-auto px-6 py-12">
<button <button
onClick={() => navigate('/events')} onClick={() => navigate('/events')}
className="inline-flex items-center text-[var(--purple-lavender)] hover:text-[var(--orange-light)] transition-colors mb-8" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors mb-8"
data-testid="back-to-events-button" data-testid="back-to-events-button"
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
@@ -79,7 +79,7 @@ const EventDetails = () => {
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-xl"> <div className="bg-[var(--neutral-800)]/20 p-4 rounded-xl">
<Calendar className="h-10 w-10 text-[var(--purple-lavender)]" /> <Calendar className="h-10 w-10 text-brand-purple " />
</div> </div>
{event.user_rsvp_status && ( {event.user_rsvp_status && (
<Badge <Badge
@@ -102,7 +102,7 @@ const EventDetails = () => {
</h1> </h1>
<div className="space-y-4 text-lg"> <div className="space-y-4 text-lg">
<div className="flex items-center gap-3 text-[var(--purple-lavender)]"> <div className="flex items-center gap-3 text-brand-purple ">
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleDateString('en-US', { {new Date(event.start_at).toLocaleDateString('en-US', {
@@ -113,18 +113,18 @@ const EventDetails = () => {
})} })}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-[var(--purple-lavender)]"> <div className="flex items-center gap-3 text-brand-purple ">
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -{' '} {new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -{' '}
{new Date(event.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(event.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-[var(--purple-lavender)]"> <div className="flex items-center gap-3 text-brand-purple ">
<MapPin className="h-5 w-5" /> <MapPin className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span>
</div> </div>
<div className="flex items-center gap-3 text-[var(--purple-lavender)]"> <div className="flex items-center gap-3 text-brand-purple ">
<Users className="h-5 w-5" /> <Users className="h-5 w-5" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{event.rsvp_count || 0} {event.rsvp_count === 1 ? 'person' : 'people'} attending {event.rsvp_count || 0} {event.rsvp_count === 1 ? 'person' : 'people'} attending
@@ -139,7 +139,7 @@ const EventDetails = () => {
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
About This Event About This Event
</h2> </h2>
<p className="text-[var(--purple-lavender)] leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{event.description} {event.description}
</p> </p>
</div> </div>
@@ -155,7 +155,7 @@ const EventDetails = () => {
disabled={rsvpLoading} disabled={rsvpLoading}
className={`rounded-full px-8 py-6 flex items-center gap-2 ${event.user_rsvp_status === 'yes' 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(--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" data-testid="rsvp-yes-button"
> >
@@ -168,7 +168,7 @@ const EventDetails = () => {
variant="outline" variant="outline"
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${event.user_rsvp_status === 'maybe' className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${event.user_rsvp_status === 'maybe'
? 'border-orange-400 bg-orange-100 text-orange-700' ? 'border-orange-400 bg-orange-100 text-orange-700'
: 'border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--lavender-300)]' : 'border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)]'
}`} }`}
data-testid="rsvp-maybe-button" data-testid="rsvp-maybe-button"
> >
@@ -195,7 +195,7 @@ const EventDetails = () => {
<h2 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Add to Your Calendar Add to Your Calendar
</h2> </h2>
<p className="text-[var(--purple-lavender)] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Never miss this event! Add it to your calendar app for reminders. Never miss this event! Add it to your calendar app for reminders.
</p> </p>
<AddToCalendarButton <AddToCalendarButton

View File

@@ -54,14 +54,14 @@ const Events = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Upcoming Events Upcoming Events
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Browse and RSVP to our community events. Browse and RSVP to our community events.
</p> </p>
</div> </div>
{loading ? ( {loading ? (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
</div> </div>
) : events.length > 0 ? ( ) : events.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
@@ -73,7 +73,7 @@ const Events = () => {
> >
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg"> <div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-[var(--purple-lavender)]" /> <Calendar className="h-6 w-6 text-brand-purple " />
</div> </div>
{getRSVPBadge(event.user_rsvp_status)} {getRSVPBadge(event.user_rsvp_status)}
</div> </div>
@@ -83,24 +83,24 @@ const Events = () => {
</h3> </h3>
{event.description && ( {event.description && (
<p className="text-[var(--purple-lavender)] mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{event.description} {event.description}
</p> </p>
)} )}
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-[var(--purple-lavender)]"> <div className="flex items-center gap-2 text-brand-purple ">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleDateString()} at{' '} {new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 text-[var(--purple-lavender)]"> <div className="flex items-center gap-2 text-brand-purple ">
<MapPin className="h-4 w-4" /> <MapPin className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span>
</div> </div>
<div className="flex items-center gap-2 text-[var(--purple-lavender)]"> <div className="flex items-center gap-2 text-brand-purple ">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.rsvp_count || 0} attending</span> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.rsvp_count || 0} attending</span>
</div> </div>
@@ -120,7 +120,7 @@ const Events = () => {
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Events Available No Events Available
</h3> </h3>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
There are no upcoming events at the moment. Check back later! There are no upcoming events at the moment. Check back later!
</p> </p>
</div> </div>

View File

@@ -37,7 +37,7 @@ const ForgotPassword = () => {
<div className="max-w-md mx-auto px-6 py-12"> <div className="max-w-md mx-auto px-6 py-12">
<div className="mb-8"> <div className="mb-8">
<Link to="/login" className="inline-flex items-center text-[var(--purple-lavender)] hover:text-[var(--orange-light)] transition-colors"> <Link to="/login" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors">
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Login Back to Login
</Link> </Link>
@@ -48,12 +48,12 @@ const ForgotPassword = () => {
<> <>
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--lavender-300)] mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--lavender-300)] mb-4">
<Mail className="h-8 w-8 text-[var(--purple-lavender)]" /> <Mail className="h-8 w-8 text-brand-purple " />
</div> </div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Forgot Password? Forgot Password?
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No worries! Enter your email and we'll send you reset instructions. No worries! Enter your email and we'll send you reset instructions.
</p> </p>
</div> </div>
@@ -69,7 +69,7 @@ const ForgotPassword = () => {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your.email@example.com" placeholder="your.email@example.com"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -82,7 +82,7 @@ const ForgotPassword = () => {
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />
</Button> </Button>
<p className="text-center text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Remember your password?{' '} Remember your password?{' '}
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium"> <Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
Login here Login here
@@ -98,11 +98,11 @@ const ForgotPassword = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Check Your Email Check Your Email
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
If an account exists for <span className="font-medium text-[var(--purple-ink)]">{email}</span>, If an account exists for <span className="font-medium text-[var(--purple-ink)]">{email}</span>,
you will receive a password reset link shortly. you will receive a password reset link shortly.
</p> </p>
<p className="text-sm text-[var(--purple-lavender)] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
The link will expire in 1 hour. If you don't see the email, check your spam folder. The link will expire in 1 hour. If you don't see the email, check your spam folder.
</p> </p>
<Link to="/login"> <Link to="/login">

View File

@@ -60,7 +60,7 @@ const Login = () => {
<div className="max-w-md mx-auto px-6 py-12"> <div className="max-w-md mx-auto px-6 py-12">
<div className="mb-8"> <div className="mb-8">
<Link to="/" className="inline-flex items-center text-[var(--purple-lavender)] hover:text-[var(--orange-light)] transition-colors"> <Link to="/" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors">
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Home Back to Home
</Link> </Link>
@@ -71,7 +71,7 @@ const Login = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Welcome Back Welcome Back
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Login to access your member dashboard. Login to access your member dashboard.
</p> </p>
</div> </div>
@@ -87,7 +87,7 @@ const Login = () => {
value={formData.email} value={formData.email}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="your.email@example.com" placeholder="your.email@example.com"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 focus:border-brand-purple "
data-testid="login-email-input " data-testid="login-email-input "
/> />
</div> </div>
@@ -106,7 +106,7 @@ const Login = () => {
value={formData.password} value={formData.password}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Enter your password" placeholder="Enter your password"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="login-password-input" data-testid="login-password-input"
/> />
</div> </div>
@@ -114,14 +114,14 @@ const Login = () => {
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50" className="w-full py-6 text-lg font-medium shadow-lg hover:scale-105 disabled:opacity-50 btn-lavender"
data-testid="login-submit-button" data-testid="login-submit-button"
> >
{loading ? 'Logging in...' : 'Login'} {loading ? 'Logging in...' : 'Login'}
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />
</Button> </Button>
<p className="text-center text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Don't have an account?{' '} Don't have an account?{' '}
<Link to="/register" className="text-[var(--orange-light)] hover:underline font-medium"> <Link to="/register" className="text-[var(--orange-light)] hover:underline font-medium">
Register here Register here

View File

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

View File

@@ -20,7 +20,7 @@ const NotFound = () => {
404 404
</h1> </h1>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<Search className="h-24 w-24 text-[var(--purple-lavender)] opacity-30" /> <Search className="h-24 w-24 text-brand-purple opacity-30" />
</div> </div>
</div> </div>
</div> </div>
@@ -33,7 +33,7 @@ const NotFound = () => {
Page Not Found Page Not Found
</h2> </h2>
<p <p
className="text-lg text-[var(--purple-lavender)] mb-8 max-w-md mx-auto" className="text-lg text-brand-purple mb-8 max-w-md mx-auto"
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
> >
Oops! The page you're looking for doesn't exist. It might have been moved or deleted. Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
@@ -44,14 +44,14 @@ const NotFound = () => {
<Button <Button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
variant="outline" variant="outline"
className="rounded-xl border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--lavender-700)] px-6 py-6" className="rounded-xl border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-700)] px-6 py-6"
> >
<ArrowLeft className="h-5 w-5 mr-2" /> <ArrowLeft className="h-5 w-5 mr-2" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="rounded-xl bg-gradient-to-r from-[var(--purple-lavender)] to-[var(--purple-ink)] hover:from-[var(--purple-ink)] hover:to-[var(--purple-lavender)] text-white px-6 py-6" className="rounded-xl bg-gradient-to-r from-brand-purple to-[var(--purple-ink)] hover:from-[var(--purple-ink)] hover:to-brand-purple text-white px-6 py-6"
> >
<Home className="h-5 w-5 mr-2" /> <Home className="h-5 w-5 mr-2" />
Back to Home Back to Home
@@ -61,13 +61,13 @@ const NotFound = () => {
{/* Help Text */} {/* Help Text */}
<div className="mt-8 pt-8 border-t border-[var(--neutral-800)]"> <div className="mt-8 pt-8 border-t border-[var(--neutral-800)]">
<p <p
className="text-sm text-[var(--purple-lavender)]" className="text-sm text-brand-purple "
style={{ fontFamily: "'Nunito Sans', sans-serif" }} style={{ fontFamily: "'Nunito Sans', sans-serif" }}
> >
Need help? Contact us at{' '} Need help? Contact us at{' '}
<a <a
href="mailto:support@loaftx.org" href="mailto:support@loaftx.org"
className="text-[var(--purple-lavender)] hover:text-[var(--purple-ink)] font-semibold underline" className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
> >
support@loaftx.org support@loaftx.org
</a> </a>

View File

@@ -25,7 +25,7 @@ const PaymentCancel = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Cancelled Payment Cancelled
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)] max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your payment was cancelled. No charges have been made to your account. Your payment was cancelled. No charges have been made to your account.
</p> </p>
</div> </div>
@@ -37,7 +37,7 @@ const PaymentCancel = () => {
</h2> </h2>
<div className="space-y-6 mb-8"> <div className="space-y-6 mb-8">
<p className="text-[var(--purple-lavender)] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You cancelled the payment process or closed the checkout page. Your membership has not been activated yet. You cancelled the payment process or closed the checkout page. Your membership has not been activated yet.
</p> </p>
@@ -47,14 +47,14 @@ const PaymentCancel = () => {
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<CreditCard className="h-5 w-5 text-[var(--purple-lavender)] flex-shrink-0 mt-0.5" /> <CreditCard className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
<span className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Return to the plans page to complete your subscription Return to the plans page to complete your subscription
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<Mail className="h-5 w-5 text-[var(--purple-lavender)] flex-shrink-0 mt-0.5" /> <Mail className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
<span className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Contact us if you experienced any issues during checkout Contact us if you experienced any issues during checkout
</span> </span>
</li> </li>
@@ -62,7 +62,7 @@ const PaymentCancel = () => {
</div> </div>
<div className="bg-[var(--lavender-300)] p-6 rounded-xl"> <div className="bg-[var(--lavender-300)] p-6 rounded-xl">
<p className="text-sm text-[var(--purple-lavender)] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span className="font-medium text-[var(--purple-ink)]">Note:</span>{' '} <span className="font-medium text-[var(--purple-ink)]">Note:</span>{' '}
Your membership application is still validated. You can complete payment whenever you're ready. Your membership application is still validated. You can complete payment whenever you're ready.
</p> </p>
@@ -82,7 +82,7 @@ const PaymentCancel = () => {
<Button <Button
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
variant="outline" variant="outline"
className="border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--purple-lavender)] hover:text-white rounded-full px-8 py-6 text-lg font-semibold" className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
data-testid="back-to-dashboard-button" data-testid="back-to-dashboard-button"
> >
<ArrowLeft className="mr-2 h-5 w-5" /> <ArrowLeft className="mr-2 h-5 w-5" />
@@ -96,13 +96,13 @@ const PaymentCancel = () => {
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3 text-center" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
Need Assistance? Need Assistance?
</h3> </h3>
<p className="text-[var(--purple-lavender)] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
If you encountered any technical issues or have questions about the payment process, our support team is here to help. If you encountered any technical issues or have questions about the payment process, our support team is here to help.
</p> </p>
<div className="text-center"> <div className="text-center">
<a <a
href="mailto:support@loaf.org" href="mailto:support@loaf.org"
className="text-[var(--orange-light)] hover:text-[var(--purple-lavender)] font-medium text-lg" className="text-[var(--orange-light)] hover:text-brand-purple font-medium text-lg"
> >
support@loaf.org support@loaf.org
</a> </a>

View File

@@ -36,7 +36,7 @@ const PaymentSuccess = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Successful! Payment Successful!
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)] max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Thank you for your payment. Your LOAF membership is now active! Thank you for your payment. Your LOAF membership is now active!
</p> </p>
</div> </div>
@@ -55,25 +55,25 @@ const PaymentSuccess = () => {
<ul className="space-y-3"> <ul className="space-y-3">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" /> <CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
<span className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your membership is now active and you have full access to all member benefits Your membership is now active and you have full access to all member benefits
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" /> <CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
<span className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You can now RSVP and attend members-only events You can now RSVP and attend members-only events
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" /> <CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
<span className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Access the community directory and connect with other members Access the community directory and connect with other members
</span> </span>
</li> </li>
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" /> <CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
<span className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You'll receive our newsletter with exclusive updates and announcements You'll receive our newsletter with exclusive updates and announcements
</span> </span>
</li> </li>
@@ -82,11 +82,11 @@ const PaymentSuccess = () => {
{sessionId && ( {sessionId && (
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-xl"> <div className="bg-[var(--neutral-800)]/20 p-4 rounded-xl">
<p className="text-sm text-[var(--purple-lavender)] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span className="font-medium text-[var(--purple-ink)]">Transaction ID:</span>{' '} <span className="font-medium text-[var(--purple-ink)]">Transaction ID:</span>{' '}
<span className="font-mono text-xs">{sessionId}</span> <span className="font-mono text-xs">{sessionId}</span>
</p> </p>
<p className="text-xs text-[var(--purple-lavender)] text-center mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xs text-brand-purple text-center mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
A confirmation email has been sent to your registered email address. A confirmation email has been sent to your registered email address.
</p> </p>
</div> </div>
@@ -106,7 +106,7 @@ const PaymentSuccess = () => {
<Button <Button
onClick={() => navigate('/events')} onClick={() => navigate('/events')}
variant="outline" variant="outline"
className="border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--purple-lavender)] hover:text-white rounded-full px-8 py-6 text-lg font-semibold" className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
data-testid="browse-events-button" data-testid="browse-events-button"
> >
<Calendar className="mr-2 h-5 w-5" /> <Calendar className="mr-2 h-5 w-5" />
@@ -117,11 +117,11 @@ const PaymentSuccess = () => {
{/* Additional Info */} {/* Additional Info */}
<div className="text-center"> <div className="text-center">
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Need help? Contact us at{' '} Need help? Contact us at{' '}
<a <a
href="mailto:support@loaf.org" href="mailto:support@loaf.org"
className="text-[var(--orange-light)] hover:text-[var(--purple-lavender)] font-medium" className="text-[var(--orange-light)] hover:text-brand-purple font-medium"
> >
support@loaf.org support@loaf.org
</a> </a>

View File

@@ -217,27 +217,27 @@ const Plans = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Membership Plans Membership Plans
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)] max-w-2xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple max-w-2xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Choose the membership plan that works best for you and become part of our vibrant community. Choose the membership plan that works best for you and become part of our vibrant community.
</p> </p>
</div> </div>
{/* Status Banner */} {/* Status Banner */}
{statusInfo && statusInfo.title && ( {statusInfo && statusInfo.title && (
<Card className="max-w-3xl mx-auto mb-8 p-6 bg-gradient-to-r from-[var(--lavender-300)] to-[var(--neutral-800)]/30 border-2 border-[var(--purple-lavender)]"> <Card className="max-w-3xl mx-auto mb-8 p-6 bg-gradient-to-r from-[var(--lavender-300)] to-[var(--neutral-800)]/30 border-2 border-brand-purple ">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<AlertCircle className="h-6 w-6 text-[var(--purple-lavender)] flex-shrink-0 mt-1" /> <AlertCircle className="h-6 w-6 text-brand-purple flex-shrink-0 mt-1" />
<div className="flex-1"> <div className="flex-1">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{statusInfo.title} {statusInfo.title}
</h3> </h3>
<p className="text-[var(--purple-lavender)] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{statusInfo.message} {statusInfo.message}
</p> </p>
{statusInfo.action && statusInfo.actionLink && ( {statusInfo.action && statusInfo.actionLink && (
<Button <Button
onClick={() => navigate(statusInfo.actionLink)} onClick={() => navigate(statusInfo.actionLink)}
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-ink)] rounded-full" className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full"
> >
{statusInfo.action} {statusInfo.action}
</Button> </Button>
@@ -249,8 +249,8 @@ const Plans = () => {
{loading ? ( {loading ? (
<div className="text-center py-20"> <div className="text-center py-20">
<Loader2 className="h-12 w-12 text-[var(--purple-lavender)] mx-auto mb-4 animate-spin" /> <Loader2 className="h-12 w-12 text-brand-purple mx-auto mb-4 animate-spin" />
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
</div> </div>
) : plans.length > 0 ? ( ) : plans.length > 0 ? (
<div className={`grid gap-6 sm:gap-8 mx-auto ${plans.length === 1 <div className={`grid gap-6 sm:gap-8 mx-auto ${plans.length === 1
@@ -266,19 +266,19 @@ const Plans = () => {
return ( return (
<Card <Card
key={plan.id} key={plan.id}
className="p-8 bg-background rounded-2xl border-2 border-[var(--neutral-800)] hover:border-[var(--purple-lavender)] hover:shadow-xl transition-all" className="p-8 bg-background rounded-2xl border-2 border-[var(--neutral-800)] hover:border-brand-purple hover:shadow-xl transition-all"
data-testid={`plan-card-${plan.id}`} data-testid={`plan-card-${plan.id}`}
> >
{/* Plan Header */} {/* Plan Header */}
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center"> <div className="bg-[var(--neutral-800)]/20 p-4 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<CreditCard className="h-8 w-8 text-[var(--purple-lavender)]" /> <CreditCard className="h-8 w-8 text-brand-purple " />
</div> </div>
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{plan.name} {plan.name}
</h2> </h2>
{plan.description && ( {plan.description && (
<p className="text-sm text-[var(--purple-lavender)] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{plan.description} {plan.description}
</p> </p>
)} )}
@@ -286,18 +286,18 @@ const Plans = () => {
{/* Pricing */} {/* Pricing */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Starting at Starting at
</div> </div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="text-2xl sm:text-3xl md:text-4xl font-bold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(minimumPrice)} {formatPrice(minimumPrice)}
</div> </div>
{suggestedPrice > minimumPrice && ( {suggestedPrice > minimumPrice && (
<div className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Suggested: {formatPrice(suggestedPrice)} Suggested: {formatPrice(suggestedPrice)}
</div> </div>
)} )}
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{getBillingCycleLabel(plan.billing_cycle)} {getBillingCycleLabel(plan.billing_cycle)}
</p> </p>
{plan.allow_donation && ( {plan.allow_donation && (
@@ -356,7 +356,7 @@ const Plans = () => {
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Plans Available No Plans Available
</h3> </h3>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Membership plans are not currently available. Please check back later! Membership plans are not currently available. Please check back later!
</p> </p>
</div> </div>
@@ -368,13 +368,13 @@ const Plans = () => {
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4 text-center" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
Need Help Choosing? Need Help Choosing?
</h3> </h3>
<p className="text-[var(--purple-lavender)] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
If you have any questions about our membership plans or need assistance, please contact us. If you have any questions about our membership plans or need assistance, please contact us.
</p> </p>
<div className="text-center"> <div className="text-center">
<a <a
href="mailto:support@loaf.org" href="mailto:support@loaf.org"
className="text-[var(--orange-light)] hover:text-[var(--purple-lavender)] font-medium" className="text-[var(--orange-light)] hover:text-brand-purple font-medium"
> >
support@loaf.org support@loaf.org
</a> </a>
@@ -390,7 +390,7 @@ const Plans = () => {
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Choose Your Amount Choose Your Amount
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedPlan?.name} - {getBillingCycleLabel(selectedPlan?.billing_cycle)} {selectedPlan?.name} - {getBillingCycleLabel(selectedPlan?.billing_cycle)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -402,7 +402,7 @@ const Plans = () => {
Amount (USD) * Amount (USD) *
</Label> </Label>
<div className="relative mt-2"> <div className="relative mt-2">
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-[var(--purple-lavender)] text-lg font-semibold"> <span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-brand-purple text-lg font-semibold">
$ $
</span> </span>
<Input <Input
@@ -412,11 +412,11 @@ const Plans = () => {
min={selectedPlan ? (selectedPlan.minimum_price_cents / 100).toFixed(2) : "30.00"} min={selectedPlan ? (selectedPlan.minimum_price_cents / 100).toFixed(2) : "30.00"}
value={amountInput} value={amountInput}
onChange={(e) => setAmountInput(e.target.value)} onChange={(e) => setAmountInput(e.target.value)}
className="pl-8 h-14 text-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="pl-8 h-14 text-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="50.00" placeholder="50.00"
/> />
</div> </div>
<p className="text-sm text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Minimum: {selectedPlan ? formatPrice(selectedPlan.minimum_price_cents || 3000) : '$30.00'} Minimum: {selectedPlan ? formatPrice(selectedPlan.minimum_price_cents || 3000) : '$30.00'}
</p> </p>
</div> </div>

View File

@@ -8,10 +8,10 @@ import { Label } from '../components/ui/label';
import { Textarea } from '../components/ui/textarea'; import { Textarea } from '../components/ui/textarea';
import { toast } from 'sonner'; import { toast } from 'sonner';
import Navbar from '../components/Navbar'; import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter'; import { User, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2, Eye, CreditCard, Handshake, ArrowLeft } from 'lucide-react';
import { User, Save, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2 } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
import ChangePasswordDialog from '../components/ChangePasswordDialog'; import ChangePasswordDialog from '../components/ChangePasswordDialog';
import { useNavigate } from 'react-router-dom';
const Profile = () => { const Profile = () => {
const { user } = useAuth(); const { user } = useAuth();
@@ -22,10 +22,13 @@ const Profile = () => {
const [previewImage, setPreviewImage] = useState(null); const [previewImage, setPreviewImage] = useState(null);
const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); // Default 50MB const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); // Default 50MB in bytes const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
const [activeTab, setActiveTab] = useState('account');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [initialFormData, setInitialFormData] = useState(null);
const navigate = useNavigate();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
// Personal Information
first_name: '', first_name: '',
last_name: '', last_name: '',
phone: '', phone: '',
@@ -33,19 +36,15 @@ const Profile = () => {
city: '', city: '',
state: '', state: '',
zipcode: '', zipcode: '',
// Partner Information
partner_first_name: '', partner_first_name: '',
partner_last_name: '', partner_last_name: '',
partner_is_member: false, partner_is_member: false,
partner_plan_to_become_member: false, partner_plan_to_become_member: false,
// Newsletter Preferences
newsletter_publish_name: false, newsletter_publish_name: false,
newsletter_publish_photo: false, newsletter_publish_photo: false,
newsletter_publish_birthday: false, newsletter_publish_birthday: false,
newsletter_publish_none: false, newsletter_publish_none: false,
// Volunteer Interests (array)
volunteer_interests: [], volunteer_interests: [],
// Member Directory Settings
show_in_directory: false, show_in_directory: false,
directory_email: '', directory_email: '',
directory_bio: '', directory_bio: '',
@@ -60,6 +59,14 @@ const Profile = () => {
fetchProfile(); fetchProfile();
}, []); }, []);
// Track unsaved changes
useEffect(() => {
if (initialFormData) {
const hasChanges = JSON.stringify(formData) !== JSON.stringify(initialFormData);
setHasUnsavedChanges(hasChanges);
}
}, [formData, initialFormData]);
const fetchConfig = async () => { const fetchConfig = async () => {
try { try {
const response = await api.get('/config'); const response = await api.get('/config');
@@ -67,7 +74,6 @@ const Profile = () => {
setMaxFileSizeBytes(response.data.max_file_size_bytes); setMaxFileSizeBytes(response.data.max_file_size_bytes);
} catch (error) { } catch (error) {
console.error('Failed to fetch config, using defaults:', error); console.error('Failed to fetch config, using defaults:', error);
// Keep default values if fetch fails
} }
}; };
@@ -77,8 +83,7 @@ const Profile = () => {
setProfileData(response.data); setProfileData(response.data);
setProfilePhotoUrl(response.data.profile_photo_url); setProfilePhotoUrl(response.data.profile_photo_url);
setPreviewImage(response.data.profile_photo_url); setPreviewImage(response.data.profile_photo_url);
setFormData({ const newFormData = {
// Personal Information
first_name: response.data.first_name || '', first_name: response.data.first_name || '',
last_name: response.data.last_name || '', last_name: response.data.last_name || '',
phone: response.data.phone || '', phone: response.data.phone || '',
@@ -86,19 +91,15 @@ const Profile = () => {
city: response.data.city || '', city: response.data.city || '',
state: response.data.state || '', state: response.data.state || '',
zipcode: response.data.zipcode || '', zipcode: response.data.zipcode || '',
// Partner Information
partner_first_name: response.data.partner_first_name || '', partner_first_name: response.data.partner_first_name || '',
partner_last_name: response.data.partner_last_name || '', partner_last_name: response.data.partner_last_name || '',
partner_is_member: response.data.partner_is_member || false, partner_is_member: response.data.partner_is_member || false,
partner_plan_to_become_member: response.data.partner_plan_to_become_member || false, partner_plan_to_become_member: response.data.partner_plan_to_become_member || false,
// Newsletter Preferences
newsletter_publish_name: response.data.newsletter_publish_name || false, newsletter_publish_name: response.data.newsletter_publish_name || false,
newsletter_publish_photo: response.data.newsletter_publish_photo || false, newsletter_publish_photo: response.data.newsletter_publish_photo || false,
newsletter_publish_birthday: response.data.newsletter_publish_birthday || false, newsletter_publish_birthday: response.data.newsletter_publish_birthday || false,
newsletter_publish_none: response.data.newsletter_publish_none || false, newsletter_publish_none: response.data.newsletter_publish_none || false,
// Volunteer Interests
volunteer_interests: response.data.volunteer_interests || [], volunteer_interests: response.data.volunteer_interests || [],
// Member Directory Settings
show_in_directory: response.data.show_in_directory || false, show_in_directory: response.data.show_in_directory || false,
directory_email: response.data.directory_email || '', directory_email: response.data.directory_email || '',
directory_bio: response.data.directory_bio || '', directory_bio: response.data.directory_bio || '',
@@ -106,7 +107,9 @@ const Profile = () => {
directory_phone: response.data.directory_phone || '', directory_phone: response.data.directory_phone || '',
directory_dob: response.data.directory_dob || '', directory_dob: response.data.directory_dob || '',
directory_partner_name: response.data.directory_partner_name || '' directory_partner_name: response.data.directory_partner_name || ''
}); };
setFormData(newFormData);
setInitialFormData(newFormData);
} catch (error) { } catch (error) {
toast.error('Failed to load profile'); toast.error('Failed to load profile');
} }
@@ -131,7 +134,6 @@ const Profile = () => {
})); }));
}; };
// Volunteer interest options
const volunteerOptions = [ const volunteerOptions = [
'Event Planning', 'Event Planning',
'Social Media', 'Social Media',
@@ -149,13 +151,11 @@ const Profile = () => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('Please select an image file'); toast.error('Please select an image file');
return; return;
} }
// Validate file size
if (file.size > maxFileSizeBytes) { if (file.size > maxFileSizeBytes) {
toast.error(`File size must be less than ${maxFileSizeMB}MB`); toast.error(`File size must be less than ${maxFileSizeMB}MB`);
return; return;
@@ -203,6 +203,8 @@ const Profile = () => {
try { try {
await api.put('/users/profile', formData); await api.put('/users/profile', formData);
toast.success('Profile updated successfully!'); toast.success('Profile updated successfully!');
setInitialFormData(formData);
setHasUnsavedChanges(false);
fetchProfile(); fetchProfile();
} catch (error) { } catch (error) {
toast.error('Failed to update profile'); toast.error('Failed to update profile');
@@ -211,82 +213,94 @@ const Profile = () => {
} }
}; };
const tabs = [
{ id: 'account', label: 'Account & Privacy', shortLabel: 'Account', icon: Lock },
{ id: 'bio', label: 'My Bio & Directory', shortLabel: 'Bio & Directory', icon: User },
{ id: 'engagement', label: 'Engagement', shortLabel: 'Engagement', icon: Handshake }
];
if (!profileData) { if (!profileData) {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-white dark:bg-[var(--purple-deep)]">
<Navbar /> <Navbar />
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p> <p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p>
</div> </div>
</div> </div>
); );
} }
return ( // Account & Privacy Tab Content
<div className="min-h-screen bg-background"> const AccountPrivacyContent = () => (
<Navbar /> <div className="space-y-6 ">
<div className="max-w-4xl mx-auto px-6 py-12"> <Card className="space-y-6 px-6 pb-6">
<div className="mb-8"> <div className="bg-brand-purple text-white px-4 py-3 rounded-t-xl -mx-6 -mt-6 mb-6">
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Account & Privacy</h3>
My Profile </div>
</h1> <div>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Login Email</p>
Update your personal information below. <p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p>
</p>
</div> </div>
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg"> <div className="flex items-center justify-between">
{/* Read-only Information */}
<div className="mb-8 pb-8 border-b border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<User className="h-6 w-6 text-[var(--purple-lavender)]" />
Account Information
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p> <p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Password</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p> <p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}></p>
</div> </div>
<div>
<p className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Status</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.status.replace('_', ' ')}</p>
</div>
<div>
<p className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.role}</p>
</div>
<div>
<p className="text-sm text-[var(--purple-lavender)] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(profileData.date_of_birth).toLocaleDateString()}
</p>
</div>
</div>
<div className="mt-6">
<Button <Button
type="button" type="button"
onClick={() => setPasswordDialogOpen(true)} onClick={() => setPasswordDialogOpen(true)}
variant="outline" variant="outline"
className="border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--lavender-300)] rounded-full px-6 py-3" className="border-2 border-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg px-4 py-2"
> >
<Lock className="h-4 w-4 mr-2" /> Change
Change Password
</Button> </Button>
</div> </div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Status</p>
<p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{profileData.status?.replace('_', ' ') || 'Active'}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</p>
<div className="flex items-center gap-2">
<CreditCard className="h-6 w-6 text-brand-purple" />
</div>
</div>
<Button
type="button"
variant="outline"
className="border-2 border-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg px-4 py-2"
>
Manage
</Button>
</div>
</Card>
</div>
);
// My Bio & Directory Tab Content
const BioDirectoryContent = () => (
<Card className="space-y-6 px-6 pb-6">
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-6 mb-6">
<h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>My Bio & Directory</h3>
</div> </div>
{/* Profile Photo Section */} {/* Profile Photo Section */}
<div className="pb-8 mb-8 border-b border-[var(--neutral-800)]"> <div className="pb-6 border-b border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Camera className="h-6 w-6 text-[var(--purple-lavender)]" /> <Camera className="h-5 w-5 text-brand-purple" />
Profile Photo Profile Photo
</h2> </h4>
<div className="flex flex-col md:flex-row items-center gap-6"> <div className="flex flex-col md:flex-row items-center gap-6">
<Avatar className="h-32 w-32 border-4 border-[var(--neutral-800)]"> <Avatar className="h-24 w-24 border-4 border-[var(--neutral-800)]">
<AvatarImage src={previewImage} alt="Profile" /> <AvatarImage src={previewImage} alt="Profile" />
<AvatarFallback className="bg-[var(--lavender-300)] text-[var(--purple-lavender)] text-3xl"> <AvatarFallback className="bg-[var(--lavender-300)] text-brand-purple text-2xl">
{profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)} {profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -304,7 +318,7 @@ const Profile = () => {
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploadingPhoto} disabled={uploadingPhoto}
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-ink)] rounded-full px-6 py-3" className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-4 py-2"
> >
<Upload className="h-4 w-4 mr-2" /> <Upload className="h-4 w-4 mr-2" />
{uploadingPhoto ? 'Uploading...' : 'Upload Photo'} {uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
@@ -316,27 +330,27 @@ const Profile = () => {
onClick={handlePhotoDelete} onClick={handlePhotoDelete}
disabled={uploadingPhoto} disabled={uploadingPhoto}
variant="outline" variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-6 py-3" className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-4 py-2"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete Photo Delete Photo
</Button> </Button>
)} )}
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload a profile photo (Max {maxFileSizeMB}MB) Max {maxFileSizeMB}MB
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Editable Form */} {/* Personal Information */}
<form onSubmit={handleSubmit} className="space-y-6" data-testid="profile-form"> <div className="space-y-4">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Personal Information Personal Information
</h2> </h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="first_name">First Name</Label> <Label htmlFor="first_name">First Name</Label>
<Input <Input
@@ -344,7 +358,7 @@ const Profile = () => {
name="first_name" name="first_name"
value={formData.first_name} value={formData.first_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="first-name-input" data-testid="first-name-input"
/> />
</div> </div>
@@ -355,7 +369,7 @@ const Profile = () => {
name="last_name" name="last_name"
value={formData.last_name} value={formData.last_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="last-name-input" data-testid="last-name-input"
/> />
</div> </div>
@@ -369,7 +383,7 @@ const Profile = () => {
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="phone-input" data-testid="phone-input"
/> />
</div> </div>
@@ -381,12 +395,12 @@ const Profile = () => {
name="address" name="address"
value={formData.address} value={formData.address}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="address-input" data-testid="address-input"
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
<Label htmlFor="city">City</Label> <Label htmlFor="city">City</Label>
<Input <Input
@@ -394,7 +408,7 @@ const Profile = () => {
name="city" name="city"
value={formData.city} value={formData.city}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="city-input" data-testid="city-input"
/> />
</div> </div>
@@ -405,7 +419,7 @@ const Profile = () => {
name="state" name="state"
value={formData.state} value={formData.state}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="state-input" data-testid="state-input"
/> />
</div> </div>
@@ -416,180 +430,23 @@ const Profile = () => {
name="zipcode" name="zipcode"
value={formData.zipcode} value={formData.zipcode}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="zipcode-input" data-testid="zipcode-input"
/> />
</div> </div>
</div> </div>
{/* Section 2: Partner Information */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Heart className="h-6 w-6 text-[var(--orange-light)]" />
Partner Information
</h2>
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<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-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]"
placeholder="Optional"
/>
</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-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]"
placeholder="Optional"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="partner_is_member"
name="partner_is_member"
checked={formData.partner_is_member}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[var(--purple-lavender)] border-2 border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]"
/>
<Label htmlFor="partner_is_member" className="cursor-pointer text-[var(--purple-ink)]">
My partner is a current member
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="partner_plan_to_become_member"
name="partner_plan_to_become_member"
checked={formData.partner_plan_to_become_member}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[var(--purple-lavender)] border-2 border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]"
/>
<Label htmlFor="partner_plan_to_become_member" className="cursor-pointer text-[var(--purple-ink)]">
My partner plans to become a member
</Label>
</div>
</div>
</div>
</div> </div>
{/* Section 3: Newsletter Preferences */} {/* Member Directory Settings */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]"> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6 text-[var(--green-light)]" /> <BookUser className="h-5 w-5 text-[var(--orange-light)]" />
Newsletter Preferences
</h2>
<p className="text-[var(--purple-lavender)] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Choose what information you'd like published in our member newsletter.
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_name"
name="newsletter_publish_name"
checked={formData.newsletter_publish_name}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[var(--purple-lavender)] border-2 border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]"
/>
<Label htmlFor="newsletter_publish_name" className="cursor-pointer text-[var(--purple-ink)]">
Publish my name
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_photo"
name="newsletter_publish_photo"
checked={formData.newsletter_publish_photo}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[var(--purple-lavender)] border-2 border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]"
/>
<Label htmlFor="newsletter_publish_photo" className="cursor-pointer text-[var(--purple-ink)]">
Publish my photo
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_birthday"
name="newsletter_publish_birthday"
checked={formData.newsletter_publish_birthday}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[var(--purple-lavender)] border-2 border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]"
/>
<Label htmlFor="newsletter_publish_birthday" className="cursor-pointer text-[var(--purple-ink)]">
Publish my birthday
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_none"
name="newsletter_publish_none"
checked={formData.newsletter_publish_none}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[var(--purple-lavender)] border-2 border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]"
/>
<Label htmlFor="newsletter_publish_none" className="cursor-pointer text-[var(--purple-ink)]">
Do not publish any information
</Label>
</div>
</div>
</div>
{/* Section 4: Volunteer Interests */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Users className="h-6 w-6 text-[var(--purple-lavender)]" />
Volunteer Interests
</h2>
<p className="text-[var(--purple-lavender)] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Select areas where you'd like to volunteer and help our community.
</p>
<div className="grid md:grid-cols-2 gap-3">
{volunteerOptions.map(option => (
<div key={option} className="flex items-center gap-3">
<input
type="checkbox"
id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
checked={formData.volunteer_interests.includes(option)}
onChange={() => handleVolunteerToggle(option)}
className="w-5 h-5 text-[var(--purple-lavender)] border-2 border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]"
/>
<Label
htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
className="cursor-pointer text-[var(--purple-ink)]"
>
{option}
</Label>
</div>
))}
</div>
</div>
{/* Section 5: Member Directory Settings */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<BookUser className="h-6 w-6 text-[var(--orange-light)]" />
Member Directory Settings Member Directory Settings
</h2> </h4>
<p className="text-[var(--purple-lavender)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Control your visibility and information in the member directory. Control your visibility and information in the member directory.
</p> </p>
<div className="space-y-6">
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg"> <div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg">
<input <input
type="checkbox" type="checkbox"
@@ -597,7 +454,7 @@ const Profile = () => {
name="show_in_directory" name="show_in_directory"
checked={formData.show_in_directory} checked={formData.show_in_directory}
onChange={handleCheckboxChange} onChange={handleCheckboxChange}
className="w-5 h-5 text-[var(--purple-lavender)] border-2 border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]" className="ui-checkbox"
/> />
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium"> <Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium">
Include me in the member directory Include me in the member directory
@@ -605,7 +462,7 @@ const Profile = () => {
</div> </div>
{formData.show_in_directory && ( {formData.show_in_directory && (
<div className="space-y-6 pl-4 border-l-4 border-[var(--neutral-800)]"> <div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
<div> <div>
<Label htmlFor="directory_email">Directory Email</Label> <Label htmlFor="directory_email">Directory Email</Label>
<Input <Input
@@ -614,7 +471,7 @@ const Profile = () => {
type="email" type="email"
value={formData.directory_email} value={formData.directory_email}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - email to show in directory" placeholder="Optional - email to show in directory"
/> />
</div> </div>
@@ -626,7 +483,7 @@ const Profile = () => {
name="directory_bio" name="directory_bio"
value={formData.directory_bio} value={formData.directory_bio}
onChange={handleInputChange} onChange={handleInputChange}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] min-h-[100px]" className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
placeholder="Tell other members about yourself..." placeholder="Tell other members about yourself..."
/> />
</div> </div>
@@ -638,7 +495,7 @@ const Profile = () => {
name="directory_address" name="directory_address"
value={formData.directory_address} value={formData.directory_address}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - address to show in directory" placeholder="Optional - address to show in directory"
/> />
</div> </div>
@@ -651,7 +508,7 @@ const Profile = () => {
type="tel" type="tel"
value={formData.directory_phone} value={formData.directory_phone}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - phone to show in directory" placeholder="Optional - phone to show in directory"
/> />
</div> </div>
@@ -664,7 +521,7 @@ const Profile = () => {
type="date" type="date"
value={formData.directory_dob} value={formData.directory_dob}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/> />
</div> </div>
@@ -675,36 +532,305 @@ const Profile = () => {
name="directory_partner_name" name="directory_partner_name"
value={formData.directory_partner_name} value={formData.directory_partner_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - partner name to show in directory" placeholder="Optional - partner name to show in directory"
/> />
</div> </div>
</div> </div>
)} )}
</div> </div>
</Card>
);
// Engagement Tab Content
const EngagementContent = () => (
<Card className="space-y-6 px-6 pb-6">
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-6 mb-6">
<h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Engagement</h3>
</div> </div>
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]"> {/* Partner Information */}
<div className="space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
Partner Information
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-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"
placeholder="Optional"
/>
</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"
placeholder="Optional"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="partner_is_member"
name="partner_is_member"
checked={formData.partner_is_member}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="partner_is_member" className="cursor-pointer text-[var(--purple-ink)]">
My partner is a current member
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="partner_plan_to_become_member"
name="partner_plan_to_become_member"
checked={formData.partner_plan_to_become_member}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="partner_plan_to_become_member" className="cursor-pointer text-[var(--purple-ink)]">
My partner plans to become a member
</Label>
</div>
</div>
</div>
{/* Newsletter Preferences */}
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-5 w-5 text-[var(--green-light)]" />
Newsletter Preferences
</h4>
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Choose what information you'd like published in our member newsletter.
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_name"
name="newsletter_publish_name"
checked={formData.newsletter_publish_name}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="newsletter_publish_name" className="cursor-pointer text-[var(--purple-ink)]">
Publish my name
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_photo"
name="newsletter_publish_photo"
checked={formData.newsletter_publish_photo}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="newsletter_publish_photo" className="cursor-pointer text-[var(--purple-ink)]">
Publish my photo
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_birthday"
name="newsletter_publish_birthday"
checked={formData.newsletter_publish_birthday}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="newsletter_publish_birthday" className="cursor-pointer text-[var(--purple-ink)]">
Publish my birthday
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_none"
name="newsletter_publish_none"
checked={formData.newsletter_publish_none}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="newsletter_publish_none" className="cursor-pointer text-[var(--purple-ink)]">
Do not publish any information
</Label>
</div>
</div>
</div>
{/* Volunteer Interests */}
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Users className="h-5 w-5 text-brand-purple" />
Volunteer Interests
</h4>
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Select areas where you'd like to volunteer and help our community.
</p>
<div className="grid md:grid-cols-2 gap-3">
{volunteerOptions.map(option => (
<div key={option} className="flex items-center gap-3">
<input
type="checkbox"
id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
checked={formData.volunteer_interests.includes(option)}
onChange={() => handleVolunteerToggle(option)}
className="ui-checkbox"
/>
<Label
htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
className="cursor-pointer text-[var(--purple-ink)]"
>
{option}
</Label>
</div>
))}
</div>
</div>
</Card>
);
return (
<div className="min-h-screen bg-background flex flex-col">
<Navbar />
<div className="flex-1 flex flex-col">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-8 w-full flex-1 pb-24">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className='space-y-4'>
<h1 className="text-4xl md:text-4xl font-semibold " style={{ fontFamily: "'Inter', sans-serif" }}>
My Profile
</h1>
<p className='text-brand-purple text-md'>Update your personal information below.</p>
</div>
{/* <Button
type="button"
variant="outline"
className="border-2 hover:bg-white/10 rounded-lg px-4 py-2"
>
<Eye className="h-4 w-4 mr-2 md:mr-2" />
<span className="hidden md:inline">Public Profile Preview</span>
<span className="md:hidden">Preview</span>
</Button> */}
</div>
{/* Main Content Div */}
<div className="overflow-hidden ">
<form onSubmit={handleSubmit} data-testid="profile-form">
{/* Mobile Tabs */}
<div className="md:hidden flex border-b border-[var(--neutral-800)] mb-4 gap-1 ">
{tabs.map((tab) => {
const IconComponent = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex flex-col items-center rounded-xl gap-1 px-3 py-3 text-xs font-medium transition-colors ${activeTab === tab.id
? 'bg-brand-purple text-white'
: 'text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]'
}`}
>
<IconComponent className="h-5 w-5" />
<span className="whitespace-nowrap">{tab.shortLabel}</span>
</button>
);
})}
</div>
{/* Desktop Layout */}
<div className="flex">
{/* Desktop Sidebar Tabs */}
<div className="hidden md:flex flex-col w-64 border-[var(--neutral-800)] mr-4 gap-2">
{tabs.map((tab) => {
const IconComponent = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-left font-medium transition-colors ${activeTab === tab.id
? 'bg-brand-purple text-white'
: 'text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]'
}`}
>
<IconComponent className="h-5 w-5" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{/* Content Area */}
<div className="flex-1 p-6 min-h-[500px]">
{activeTab === 'account' && <AccountPrivacyContent />}
{activeTab === 'bio' && <BioDirectoryContent />}
{activeTab === 'engagement' && <EngagementContent />}
</div>
</div>
</form>
</div>
</div>
{/* Sticky Footer */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-[var(--neutral-800)] px-4 sm:px-6 py-4 z-50">
<div className="max-w-5xl px-6 mx-auto flex items-center justify-between">
<div className='flex gap-2 w-full lg:justify-between md:mr-5'>
<Button <Button
type="submit" onClick={() => navigate(-1)}
className="h-fit bg-brand-purple hover:bg-brand-purple/80 rounded-lg px-6 py-2 font-medium shadow-lg w-full md:w-auto">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="flex items-center gap-2">
{hasUnsavedChanges && (
<>
<span className="h-3 w-3 rounded-full bg-[var(--orange-light)]"></span>
<span className="text-sm text-[var(--purple-ink)] " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Unsaved changes
</span>
</>
)}
</div>
<Button
type="button"
onClick={handleSubmit}
disabled={loading} disabled={loading}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-medium shadow-lg disabled:opacity-50" className="bg-brand-purple text-white hover:bg-brand-dark-lavender rounded-lg px-6 py-2 font-medium shadow-lg disabled:opacity-50 w-full md:w-auto"
data-testid="save-profile-button" data-testid="save-profile-button"
> >
<Save className="h-5 w-5 mr-2" />
{loading ? 'Saving...' : 'Save Changes'} {loading ? 'Saving...' : 'Save Changes'}
</Button> </Button>
</div> </div>
</form>
</Card> </div>
</div>
</div>
<ChangePasswordDialog <ChangePasswordDialog
open={passwordDialogOpen} open={passwordDialogOpen}
onOpenChange={setPasswordDialogOpen} onOpenChange={setPasswordDialogOpen}
/> />
</div> </div>
<MemberFooter />
</div>
); );
}; };

View File

@@ -188,7 +188,7 @@ const Register = () => {
<div className="max-w-4xl mx-auto px-6 py-12"> <div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-8"> <div className="mb-8">
<Link to="/" className="inline-flex items-center text-[var(--purple-lavender)] hover:text-[var(--orange-light)] transition-colors"> <Link to="/" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors">
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Home Back to Home
</Link> </Link>
@@ -199,7 +199,7 @@ const Register = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Join Our Community Join Our Community
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Fill out the form below to start your membership journey. Fill out the form below to start your membership journey.
</p> </p>
</div> </div>
@@ -245,7 +245,7 @@ const Register = () => {
type="button" type="button"
onClick={handleBack} onClick={handleBack}
variant="outline" variant="outline"
className="rounded-full px-6 py-6 text-lg border-2 border-[var(--neutral-800)] hover:border-[var(--purple-lavender)] text-[var(--purple-ink)]" className="rounded-full px-6 py-6 text-lg border-2 border-[var(--neutral-800)] hover:border-brand-purple text-[var(--purple-ink)]"
> >
<ArrowLeft className="mr-2 h-5 w-5" /> <ArrowLeft className="mr-2 h-5 w-5" />
Back Back
@@ -258,7 +258,7 @@ const Register = () => {
<Button <Button
type="button" type="button"
onClick={handleNext} 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 Next
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />
@@ -267,7 +267,7 @@ const Register = () => {
<Button <Button
type="submit" type="submit"
disabled={loading} 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" data-testid="submit-register-button"
> >
{loading ? 'Creating Account...' : 'Create Account'} {loading ? 'Creating Account...' : 'Create Account'}
@@ -276,7 +276,7 @@ const Register = () => {
)} )}
</div> </div>
<p className="text-center text-[var(--purple-lavender)] mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-center text-brand-purple mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Already have an account?{' '} Already have an account?{' '}
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium"> <Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
Login here Login here

View File

@@ -71,12 +71,12 @@ const ResetPassword = () => {
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg"> <Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--lavender-300)] mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--lavender-300)] mb-4">
<Lock className="h-8 w-8 text-[var(--purple-lavender)]" /> <Lock className="h-8 w-8 text-brand-purple " />
</div> </div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Reset Password Reset Password
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Enter your new password below. Enter your new password below.
</p> </p>
</div> </div>
@@ -92,7 +92,7 @@ const ResetPassword = () => {
value={formData.newPassword} value={formData.newPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Enter new password (min. 6 characters)" placeholder="Enter new password (min. 6 characters)"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -106,14 +106,14 @@ const ResetPassword = () => {
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={handleInputChange} onChange={handleInputChange}
placeholder="Re-enter new password" placeholder="Re-enter new password"
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
<div className="bg-[var(--lavender-300)] border-l-4 border-[var(--purple-lavender)] p-4 rounded-lg"> <div className="bg-[var(--lavender-300)] border-l-4 border-brand-purple p-4 rounded-lg">
<div className="flex items-start"> <div className="flex items-start">
<AlertCircle className="h-5 w-5 text-[var(--purple-lavender)] mr-2 mt-0.5 flex-shrink-0" /> <AlertCircle className="h-5 w-5 text-brand-purple mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="font-medium text-[var(--purple-ink)] mb-1">Password Requirements:</p> <p className="font-medium text-[var(--purple-ink)] mb-1">Password Requirements:</p>
<ul className="list-disc list-inside space-y-1"> <ul className="list-disc list-inside space-y-1">
<li>At least 6 characters long</li> <li>At least 6 characters long</li>
@@ -132,7 +132,7 @@ const ResetPassword = () => {
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />
</Button> </Button>
<p className="text-center text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Remember your password?{' '} Remember your password?{' '}
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium"> <Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
Login here Login here

View File

@@ -52,11 +52,11 @@ const VerifyEmail = () => {
<Card className="p-6 sm:p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg text-center"> <Card className="p-6 sm:p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg text-center">
{status === 'loading' && ( {status === 'loading' && (
<> <>
<Loader2 className="h-20 w-20 text-[var(--purple-lavender)] mx-auto mb-6 animate-spin" /> <Loader2 className="h-20 w-20 text-brand-purple mx-auto mb-6 animate-spin" />
<h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Verifying Your Email... Verifying Your Email...
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Please wait while we verify your email address. Please wait while we verify your email address.
</p> </p>
</> </>
@@ -68,10 +68,10 @@ const VerifyEmail = () => {
<h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Email Verified Successfully! Email Verified Successfully!
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{message} {message}
</p> </p>
<p className="text-base text-[var(--purple-lavender)] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-base text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Next steps: Attend an event and meet a board member within 90 days. Once you've attended an event, our admin team will review your application. Next steps: Attend an event and meet a board member within 90 days. Once you've attended an event, our admin team will review your application.
</p> </p>
<Link to="/login"> <Link to="/login">
@@ -91,7 +91,7 @@ const VerifyEmail = () => {
<h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Verification Failed Verification Failed
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{message} {message}
</p> </p>
<Link to="/"> <Link to="/">

View File

@@ -169,7 +169,7 @@ const AdminBylaws = () => {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[var(--purple-lavender)]">Loading bylaws...</p> <p className="text-brand-purple ">Loading bylaws...</p>
</div> </div>
); );
} }
@@ -182,14 +182,14 @@ const AdminBylaws = () => {
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Bylaws Management Bylaws Management
</h1> </h1>
<p className="text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage LOAF governing bylaws and version history Manage LOAF governing bylaws and version history
</p> </p>
</div> </div>
{hasPermission('bylaws.create') && ( {hasPermission('bylaws.create') && (
<Button <Button
onClick={handleCreate} onClick={handleCreate}
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2" className="btn-lavender flex items-center gap-2"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Version Add Version
@@ -199,22 +199,22 @@ const AdminBylaws = () => {
{/* Current Bylaws */} {/* Current Bylaws */}
{currentBylaws ? ( {currentBylaws ? (
<Card className="p-6 border-2 border-[var(--purple-lavender)]"> <Card className="p-6 border-2 border-brand-purple ">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-gradient-to-br from-[var(--purple-lavender)] to-[var(--purple-ink)] p-3 rounded-xl"> <div className="bg-light-lavender p-3 rounded-xl">
<Scale className="h-6 w-6 text-white" /> <Scale className="h-6 w-6 " />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold text-[var(--purple-ink)]"> <h3 className="text-xl font-semibold text-[var(--purple-ink)]">
{currentBylaws.title} {currentBylaws.title}
</h3> </h3>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<Badge className="bg-[var(--green-light)] text-white"> <Badge variant={'green'} className="">
<Check className="h-3 w-3 mr-1" /> <Check className="h-3 w-3 mr-1" />
Current Version Current Version
</Badge> </Badge>
<span className="text-[var(--purple-lavender)] text-sm"> <span className="text-brand-purple text-sm">
Version {currentBylaws.version} Version {currentBylaws.version}
</span> </span>
</div> </div>
@@ -222,10 +222,10 @@ const AdminBylaws = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={() => window.open(currentBylaws.document_url, '_blank')} onClick={() => window.open(currentBylaws.document_url, '_blank')}
className="border-[var(--purple-lavender)] text-[var(--purple-lavender)]" className="border-brand-purple text-brand-purple "
> >
<ExternalLink className="h-4 w-4 mr-1" /> <ExternalLink className="h-4 w-4 mr-1" />
View View
@@ -235,24 +235,24 @@ const AdminBylaws = () => {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleEdit(currentBylaws)} onClick={() => handleEdit(currentBylaws)}
className="border-[var(--purple-lavender)] text-[var(--purple-lavender)]" className="border-brand-purple text-brand-purple "
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)} )}
{hasPermission('bylaws.delete') && ( {hasPermission('bylaws.delete') && (
<Button <Button
variant="outline" variant="outline-destructive"
size="sm" size="sm"
onClick={() => handleDelete(currentBylaws)} onClick={() => handleDelete(currentBylaws)}
className="border-red-500 text-red-500 hover:bg-red-50" className="border-red-500 text-red-500"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-4 text-sm text-[var(--purple-lavender)]"> <div className="flex items-center gap-4 text-sm text-brand-purple ">
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span> <span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
<span></span> <span></span>
<span>Document Type: <strong>{currentBylaws.document_type === 'upload' ? 'PDF Upload' : 'Link'}</strong></span> <span>Document Type: <strong>{currentBylaws.document_type === 'upload' ? 'PDF Upload' : 'Link'}</strong></span>
@@ -261,9 +261,9 @@ const AdminBylaws = () => {
) : ( ) : (
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<Scale className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" /> <Scale className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
<p className="text-[var(--purple-lavender)] text-lg mb-4">No current bylaws set</p> <p className="text-brand-purple text-lg mb-4">No current bylaws set</p>
{hasPermission('bylaws.create') && ( {hasPermission('bylaws.create') && (
<Button onClick={handleCreate} className="bg-[var(--purple-lavender)] text-white"> <Button onClick={handleCreate} className="bg-brand-purple text-white">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create Bylaws Create Bylaws
</Button> </Button>
@@ -285,7 +285,7 @@ const AdminBylaws = () => {
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-1"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-1">
{bylawsDoc.title} {bylawsDoc.title}
</h3> </h3>
<div className="flex items-center gap-3 text-sm text-[var(--purple-lavender)]"> <div className="flex items-center gap-3 text-sm text-brand-purple ">
<span>Version {bylawsDoc.version}</span> <span>Version {bylawsDoc.version}</span>
<span></span> <span></span>
<span>Effective {formatDate(bylawsDoc.effective_date)}</span> <span>Effective {formatDate(bylawsDoc.effective_date)}</span>
@@ -296,7 +296,7 @@ const AdminBylaws = () => {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => window.open(bylawsDoc.document_url, '_blank')} onClick={() => window.open(bylawsDoc.document_url, '_blank')}
className="border-[var(--purple-lavender)] text-[var(--purple-lavender)]" className="border-brand-purple text-brand-purple "
> >
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</Button> </Button>
@@ -305,7 +305,7 @@ const AdminBylaws = () => {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleEdit(bylawsDoc)} onClick={() => handleEdit(bylawsDoc)}
className="border-[var(--purple-lavender)] text-[var(--purple-lavender)]" className="border-brand-purple text-brand-purple "
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
@@ -315,7 +315,7 @@ const AdminBylaws = () => {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleDelete(bylawsDoc)} onClick={() => handleDelete(bylawsDoc)}
className="border-red-500 text-red-500 hover:bg-red-50" className="btn-outline-destructive"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -330,7 +330,7 @@ const AdminBylaws = () => {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent> <DialogContent className="overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{selectedBylaws ? 'Edit Bylaws' : 'Add Bylaws Version'} {selectedBylaws ? 'Edit Bylaws' : 'Add Bylaws Version'}
@@ -404,12 +404,12 @@ const AdminBylaws = () => {
required={!selectedBylaws} required={!selectedBylaws}
/> />
{uploadedFile && ( {uploadedFile && (
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Selected: {uploadedFile.name} Selected: {uploadedFile.name}
</p> </p>
)} )}
{selectedBylaws && !uploadedFile && ( {selectedBylaws && !uploadedFile && (
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Current file will be kept if no new file is selected Current file will be kept if no new file is selected
</p> </p>
)} )}
@@ -424,7 +424,7 @@ const AdminBylaws = () => {
placeholder="https://docs.google.com/... or https://example.com/file.pdf" placeholder="https://docs.google.com/... or https://example.com/file.pdf"
required required
/> />
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.) Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
</p> </p>
</div> </div>
@@ -455,7 +455,7 @@ const AdminBylaws = () => {
</Button> </Button>
<Button <Button
type="submit" type="submit"
className="bg-[var(--purple-lavender)] text-white" className="bg-brand-purple text-white"
disabled={submitting} disabled={submitting}
> >
{submitting ? 'Saving...' : selectedBylaws ? 'Update' : 'Create'} {submitting ? 'Saving...' : selectedBylaws ? 'Update' : 'Create'}
@@ -482,9 +482,9 @@ const AdminBylaws = () => {
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="outline"
onClick={confirmDelete} onClick={confirmDelete}
className="bg-red-500 hover:bg-red-600" className="btn-outline-destructive"
> >
Delete Delete
</Button> </Button>

View File

@@ -4,13 +4,16 @@ import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { 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 AdminDashboard = () => {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
totalMembers: 0, totalMembers: 0,
pendingValidations: 0, pendingValidations: 0,
activeMembers: 0 activeMembers: 0,
inactiveMembers: 0
}); });
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]); const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -29,7 +32,8 @@ const AdminDashboard = () => {
pendingValidations: users.filter(u => pendingValidations: users.filter(u =>
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status) ['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
).length, ).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) // Find users who have received 3+ reminders (may need personal outreach)
@@ -56,18 +60,18 @@ const AdminDashboard = () => {
return ( return (
<> <>
<div className='flex justify-between items-center'> <div className='flex flex-col md:flex-row md:justify-between md:items-center'>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Admin Dashboard Admin Dashboard
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage users, events, and membership applications. Manage users, events, and membership applications.
</p> </p>
</div> </div>
<Link to={'/'}> <Link to={'/'} className=''>
<Button <Button
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2" className="btn-lavender mb-8 md:mb-0 "
> >
<Globe /> <Globe />
View Public Site View Public Site
@@ -76,57 +80,57 @@ const AdminDashboard = () => {
</div> </div>
{/* Stats Grid */} {/* 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-center justify-between mb-4">
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-[var(--purple-lavender)]" />
</div>
</div>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.totalMembers}
</p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-pending-validations"> <div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<div className="flex items-center justify-between mb-4"> <div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
<div className="bg-orange-100 p-3 rounded-lg"> Quick Overview
<Clock className="h-6 w-6 text-orange-600" />
</div> </div>
</div> <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 ">
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> <StatCard
{loading ? '-' : stats.pendingValidations} title="Total Members"
</p> value={loading ? '-' : stats.totalMembers}
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p> icon={Users}
</Card> 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-active-members">
<div className="flex items-center 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> </div>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.activeMembers}
</p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
</Card>
</div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="grid md:grid-cols-2 gap-8"> <div className="grid md:grid-cols-2 gap-8">
<Link to="/admin/members"> <Link to="/admin/members">
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users"> <Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
<Users className="h-12 w-12 text-[var(--purple-lavender)] mb-4" /> <Users className="h-12 w-12 text-brand-purple mb-4" />
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Manage Members Manage Members
</h3> </h3>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View and manage paying members and their subscription status. View and manage paying members and their subscription status.
</p> </p>
<Button <Button
className="mt-4 bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full" className="btn-lavender mt-4"
data-testid="manage-users-button" data-testid="manage-users-button"
> >
Go to Members Go to Members
@@ -140,11 +144,11 @@ const AdminDashboard = () => {
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Validation Queue Validation Queue
</h3> </h3>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Review and validate pending membership applications. Review and validate pending membership applications.
</p> </p>
<Button <Button
className="mt-4 bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full" className="mt-4 btn-lavender"
data-testid="manage-validations-button" data-testid="manage-validations-button"
> >
View Validations View Validations
@@ -165,7 +169,7 @@ const AdminDashboard = () => {
<h3 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Needing Personal Outreach Members Needing Personal Outreach
</h3> </h3>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
These members have received multiple reminder emails. Consider calling them directly. These members have received multiple reminder emails. Consider calling them directly.
</p> </p>
</div> </div>
@@ -185,7 +189,7 @@ const AdminDashboard = () => {
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''} {user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p> <p>Email: {user.email}</p>
<p>Phone: {user.phone || 'N/A'}</p> <p>Phone: {user.phone || 'N/A'}</p>
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p> <p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
@@ -225,7 +229,7 @@ const AdminDashboard = () => {
</div> </div>
<div className="mt-6 p-4 bg-[var(--neutral-800)]/20 rounded-lg border border-[var(--neutral-800)]"> <div className="mt-6 p-4 bg-[var(--neutral-800)]/20 rounded-lg border border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email. <strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email.
A friendly phone call can help them complete the registration process and feel more welcomed to the community. A friendly phone call can help them complete the registration process and feel more welcomed to the community.
</p> </p>

View File

@@ -3,6 +3,7 @@ import { useAuth } from '../../context/AuthContext';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import StatusBadge from '@/components/StatusBadge';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -17,6 +18,14 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../components/ui/table';
import api from '../../utils/api'; import api from '../../utils/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -28,7 +37,13 @@ import {
Loader2, Loader2,
Download, Download,
FileDown, FileDown,
Calendar Calendar,
ChevronDown,
ChevronUp,
ExternalLink,
Copy,
CreditCard,
Info
} from 'lucide-react'; } from 'lucide-react';
const AdminDonations = () => { const AdminDonations = () => {
@@ -43,6 +58,7 @@ const AdminDonations = () => {
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [expandedRows, setExpandedRows] = useState(new Set());
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -157,23 +173,37 @@ const AdminDonations = () => {
}); });
}; };
const getStatusBadgeVariant = (status) => { const toggleRowExpansion = (donationId) => {
const variants = { setExpandedRows((prev) => {
completed: 'default', const newExpanded = new Set(prev);
pending: 'secondary', if (newExpanded.has(donationId)) {
failed: 'destructive' newExpanded.delete(donationId);
}; } else {
return variants[status] || 'outline'; 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) => { const getTypeBadgeColor = (type) => {
return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-[var(--purple-lavender)]'; return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-brand-purple ';
}; };
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-12 w-12 animate-spin text-[var(--purple-lavender)]" /> <Loader2 className="h-12 w-12 animate-spin text-brand-purple " />
</div> </div>
); );
} }
@@ -185,7 +215,7 @@ const AdminDonations = () => {
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Donation Management Donation Management
</h1> </h1>
<p className="text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Track and manage all donations from members and the public Track and manage all donations from members and the public
</p> </p>
</div> </div>
@@ -195,7 +225,7 @@ const AdminDonations = () => {
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Donations Total Donations
</p> </p>
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -203,7 +233,7 @@ const AdminDonations = () => {
</p> </p>
</div> </div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full"> <div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<Heart className="h-6 w-6 text-[var(--purple-lavender)]" /> <Heart className="h-6 w-6 text-brand-purple " />
</div> </div>
</div> </div>
</Card> </Card>
@@ -211,7 +241,7 @@ const AdminDonations = () => {
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Member Donations Member Donations
</p> </p>
<p className="text-3xl font-bold text-[var(--green-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-bold text-[var(--green-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -227,15 +257,15 @@ const AdminDonations = () => {
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Public Donations Public Donations
</p> </p>
<p className="text-3xl font-bold text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-bold text-brand-purple mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{stats.public_donations || 0} {stats.public_donations || 0}
</p> </p>
</div> </div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full"> <div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<Globe className="h-6 w-6 text-[var(--purple-lavender)]" /> <Globe className="h-6 w-6 text-brand-purple " />
</div> </div>
</div> </div>
</Card> </Card>
@@ -243,7 +273,7 @@ const AdminDonations = () => {
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Amount Total Amount
</p> </p>
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -251,7 +281,7 @@ const AdminDonations = () => {
</p> </p>
</div> </div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full"> <div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<DollarSign className="h-6 w-6 text-[var(--purple-lavender)]" /> <DollarSign className="h-6 w-6 text-brand-purple " />
</div> </div>
</div> </div>
</Card> </Card>
@@ -263,12 +293,12 @@ const AdminDonations = () => {
{/* Search and Export Row */} {/* Search and Export Row */}
<div className="flex flex-col md:flex-row gap-4 justify-between"> <div className="flex flex-col md:flex-row gap-4 justify-between">
<div className="flex-1 relative"> <div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[var(--purple-lavender)]" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input <Input
placeholder="Search by donor name or email..." placeholder="Search by donor name or email..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 rounded-full border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="pl-10 rounded-full border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
{hasPermission('donations.export') && ( {hasPermission('donations.export') && (
@@ -287,14 +317,14 @@ const AdminDonations = () => {
onClick={() => handleExport('all')} onClick={() => handleExport('all')}
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3" className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
> >
<FileDown className="h-4 w-4 mr-2 text-[var(--purple-lavender)]" /> <FileDown className="h-4 w-4 mr-2 text-brand-purple " />
<span className="text-[var(--purple-ink)]">Export All Donations</span> <span className="text-[var(--purple-ink)]">Export All Donations</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleExport('current')} onClick={() => handleExport('current')}
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3" className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
> >
<FileDown className="h-4 w-4 mr-2 text-[var(--purple-lavender)]" /> <FileDown className="h-4 w-4 mr-2 text-brand-purple " />
<span className="text-[var(--purple-ink)]">Export Current View</span> <span className="text-[var(--purple-ink)]">Export Current View</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -354,7 +384,7 @@ const AdminDonations = () => {
{/* Active Filters Summary */} {/* Active Filters Summary */}
{(searchQuery || typeFilter !== 'all' || statusFilter !== 'all' || startDate || endDate) && ( {(searchQuery || typeFilter !== 'all' || statusFilter !== 'all' || startDate || endDate) && (
<div className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing {filteredDonations.length} of {donations.length} donations Showing {filteredDonations.length} of {donations.length} donations
</div> </div>
)} )}
@@ -364,90 +394,207 @@ const AdminDonations = () => {
{/* Donations Table */} {/* Donations Table */}
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden"> <Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <Table>
<thead className="bg-[var(--lavender-300)] border-b-2 border-[var(--neutral-800)]"> <TableHeader>
<tr> <TableRow>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead>Donor</TableHead>
Donor <TableHead>Type</TableHead>
</th> <TableHead>Amount</TableHead>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead>Status</TableHead>
Type <TableHead>Date</TableHead>
</th> <TableHead>Payment Method</TableHead>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead className="text-center">Details</TableHead>
Amount </TableRow>
</th> </TableHeader>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableBody>
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)]">
{filteredDonations.length === 0 ? ( {filteredDonations.length === 0 ? (
<tr> <TableRow>
<td colSpan="6" className="px-6 py-12 text-center"> <TableCell colSpan={7} className="p-12 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<Heart className="h-12 w-12 text-[var(--neutral-800)]" /> <Heart className="h-12 w-12 text-[var(--neutral-800)]" />
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donations.length === 0 ? 'No donations yet' : 'No donations match your filters'} {donations.length === 0 ? 'No donations yet' : 'No donations match your filters'}
</p> </p>
</div> </div>
</td> </TableCell>
</tr> </TableRow>
) : ( ) : (
filteredDonations.map((donation) => ( filteredDonations.map((donation) => {
<tr key={donation.id} className="hover:bg-[var(--lavender-400)] transition-colors"> const isExpanded = expandedRows.has(donation.id);
<td className="px-6 py-4"> return (
<React.Fragment key={donation.id}>
<TableRow>
<TableCell>
<div> <div>
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.donor_name || 'Anonymous'} {donation.donor_name || 'Anonymous'}
</p> </p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donation.donor_email || 'No email'} {donation.donor_email || 'No email'}
</p> </p>
</div> </div>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<Badge <Badge
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none rounded-full px-3 py-1`} className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none px-3 py-1`}
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
{donation.donation_type === 'member' ? 'Member' : 'Public'} {donation.donation_type === 'member' ? 'Member' : 'Public'}
</Badge> </Badge>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.amount} {donation.amount}
</p> </p>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<Badge variant={getStatusBadgeVariant(donation.status)} className="rounded-full"> <StatusBadge status={donation.status} />
{donation.status.charAt(0).toUpperCase() + donation.status.slice(1)} </TableCell>
</Badge> <TableCell>
</td> <div className="flex items-center gap-2 text-brand-purple ">
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-[var(--purple-lavender)]">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(donation.created_at)} {formatDate(donation.created_at)}
</span> </span>
</div> </div>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif capitalize" }}>
{donation.payment_method || 'N/A'} {donation.payment_method || 'N/A'}
</p> </p>
</td> </TableCell>
</tr> <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>
)} )}
</tbody> {donation.payment_method && (
</table> <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>
);
})
)}
</TableBody>
</Table>
</div> </div>
</Card> </Card>
@@ -456,7 +603,7 @@ const AdminDonations = () => {
<Card className="p-6 bg-gradient-to-r from-[var(--lavender-400)] to-[var(--lavender-300)] rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-gradient-to-r from-[var(--lavender-400)] to-[var(--lavender-300)] rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-[var(--purple-lavender)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
This Month's Donations This Month's Donations
</p> </p>
<p className="text-2xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-2xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>

View File

@@ -213,7 +213,7 @@ const AdminEventAttendance = () => {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-[var(--purple-lavender)]">Loading event data...</div> <div className="text-brand-purple ">Loading event data...</div>
</div> </div>
); );
} }
@@ -221,8 +221,8 @@ const AdminEventAttendance = () => {
if (!event) { if (!event) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-[var(--purple-lavender)] mb-4">Event not found</p> <p className="text-brand-purple mb-4">Event not found</p>
<Button onClick={() => navigate('/admin/events')} className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white rounded-xl"> <Button onClick={() => navigate('/admin/events')} className="bg-brand-purple hover:bg-[var(--purple-ink)] text-white rounded-xl">
Back to Events Back to Events
</Button> </Button>
</div> </div>
@@ -237,7 +237,7 @@ const AdminEventAttendance = () => {
<Button <Button
onClick={() => navigate('/admin/events')} onClick={() => navigate('/admin/events')}
variant="outline" variant="outline"
className="border-[var(--neutral-800)] text-[var(--purple-lavender)] rounded-xl" className="border-[var(--neutral-800)] text-brand-purple rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
@@ -247,14 +247,14 @@ const AdminEventAttendance = () => {
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Attendance Event Attendance
</h1> </h1>
<p className="text-[var(--purple-lavender)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage RSVPs and track attendance for this event Manage RSVPs and track attendance for this event
</p> </p>
</div> </div>
</div> </div>
<Button <Button
onClick={exportToCSV} 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" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
@@ -269,7 +269,7 @@ const AdminEventAttendance = () => {
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{event.title} {event.title}
</h2> </h2>
<div className="flex flex-wrap gap-4 text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="flex flex-wrap gap-4 text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span>{moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')}</span> <span>{moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')}</span>
@@ -282,7 +282,7 @@ const AdminEventAttendance = () => {
)} )}
</div> </div>
</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'} {event.published ? 'Published' : 'Draft'}
</Badge> </Badge>
</div> </div>
@@ -292,9 +292,9 @@ const AdminEventAttendance = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl"> <Card className="p-4 bg-background border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Users className="h-8 w-8 text-[var(--purple-lavender)]" /> <Users className="h-8 w-8 text-brand-purple " />
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p> <p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
</div> </div>
</div> </div>
@@ -302,9 +302,9 @@ const AdminEventAttendance = () => {
<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"> <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> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p> <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> <p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
</div> </div>
</div> </div>
@@ -314,7 +314,7 @@ const AdminEventAttendance = () => {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserX className="h-8 w-8 text-[var(--orange-soft)]" /> <UserX className="h-8 w-8 text-[var(--orange-soft)]" />
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p> <p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
</div> </div>
</div> </div>
@@ -324,7 +324,7 @@ const AdminEventAttendance = () => {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<HelpCircle className="h-8 w-8 text-[var(--gold-warm)]" /> <HelpCircle className="h-8 w-8 text-[var(--gold-warm)]" />
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p> <p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
</div> </div>
</div> </div>
@@ -332,9 +332,9 @@ const AdminEventAttendance = () => {
<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"> <div className="flex items-center gap-3">
<Check className="h-8 w-8 text-[var(--purple-lavender)]" /> <Check className="h-8 w-8 text-brand-purple " />
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
<p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p> <p className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
</div> </div>
</div> </div>
@@ -350,8 +350,8 @@ const AdminEventAttendance = () => {
onClick={() => setActiveTab('all')} onClick={() => setActiveTab('all')}
variant={activeTab === 'all' ? 'default' : 'outline'} variant={activeTab === 'all' ? 'default' : 'outline'}
className={`rounded-xl ${activeTab === 'all' className={`rounded-xl ${activeTab === 'all'
? 'bg-[var(--purple-lavender)] 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-[var(--purple-lavender)] hover:bg-[var(--lavender-500)]' : 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
}`} }`}
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
@@ -361,8 +361,8 @@ const AdminEventAttendance = () => {
onClick={() => setActiveTab('yes')} onClick={() => setActiveTab('yes')}
variant={activeTab === 'yes' ? 'default' : 'outline'} variant={activeTab === 'yes' ? 'default' : 'outline'}
className={`rounded-xl ${activeTab === 'yes' 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-[var(--purple-lavender)] hover:bg-[var(--lavender-500)]' : 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
}`} }`}
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
@@ -373,7 +373,7 @@ const AdminEventAttendance = () => {
variant={activeTab === 'no' ? 'default' : 'outline'} variant={activeTab === 'no' ? 'default' : 'outline'}
className={`rounded-xl ${activeTab === 'no' className={`rounded-xl ${activeTab === 'no'
? 'bg-[var(--orange-soft)] hover:bg-[var(--orange-rust)] text-white' ? 'bg-[var(--orange-soft)] hover:bg-[var(--orange-rust)] text-white'
: 'border-[var(--neutral-800)] text-[var(--purple-lavender)] hover:bg-[var(--lavender-500)]' : 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
}`} }`}
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
@@ -383,8 +383,8 @@ const AdminEventAttendance = () => {
onClick={() => setActiveTab('maybe')} onClick={() => setActiveTab('maybe')}
variant={activeTab === 'maybe' ? 'default' : 'outline'} variant={activeTab === 'maybe' ? 'default' : 'outline'}
className={`rounded-xl ${activeTab === 'maybe' 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-[var(--purple-lavender)] hover:bg-[var(--lavender-500)]' : 'border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-500)]'
}`} }`}
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
@@ -395,7 +395,7 @@ const AdminEventAttendance = () => {
{/* Search and Bulk Actions */} {/* Search and Bulk Actions */}
<div className="flex flex-wrap gap-3 items-center justify-between"> <div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex-1 min-w-[200px] max-w-md relative"> <div className="flex-1 min-w-[200px] max-w-md relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--purple-lavender)]" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-brand-purple " />
<Input <Input
type="text" type="text"
placeholder="Search by name or email..." placeholder="Search by name or email..."
@@ -408,7 +408,7 @@ const AdminEventAttendance = () => {
{selectedRsvps.size > 0 && ( {selectedRsvps.size > 0 && (
<div className="flex gap-2"> <div className="flex gap-2">
<Badge className="bg-[var(--purple-lavender)] text-white px-3 py-1"> <Badge className="bg-brand-purple text-white px-3 py-1">
{selectedRsvps.size} selected {selectedRsvps.size} selected
</Badge> </Badge>
<Button <Button
@@ -477,13 +477,13 @@ const AdminEventAttendance = () => {
<td className="px-4 py-3 text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <td className="px-4 py-3 text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rsvp.user_name} {rsvp.user_name}
</td> </td>
<td className="px-4 py-3 text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <td className="px-4 py-3 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rsvp.user_email} {rsvp.user_email}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge <Badge
className={`${rsvp.rsvp_status === 'yes' className={`${rsvp.rsvp_status === 'yes'
? 'bg-[var(--green-light)]' ? 'bg-[var(--green-light)] dark:bg-[var(--green-forest)]'
: rsvp.rsvp_status === 'no' : rsvp.rsvp_status === 'no'
? 'bg-[var(--orange-soft)]' ? 'bg-[var(--orange-soft)]'
: 'bg-[var(--gold-warm)] text-[var(--purple-ink)]' : 'bg-[var(--gold-warm)] text-[var(--purple-ink)]'
@@ -498,7 +498,7 @@ const AdminEventAttendance = () => {
onClick={() => handleIndividualAttendance(rsvp.user_id, false)} onClick={() => handleIndividualAttendance(rsvp.user_id, false)}
disabled={saving} disabled={saving}
size="sm" 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" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
<Check className="h-3 w-3 mr-1" /> <Check className="h-3 w-3 mr-1" />
@@ -510,7 +510,7 @@ const AdminEventAttendance = () => {
disabled={saving} disabled={saving}
size="sm" size="sm"
variant="outline" variant="outline"
className="border-[var(--neutral-800)] text-[var(--purple-lavender)] 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" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
<X className="h-3 w-3 mr-1" /> <X className="h-3 w-3 mr-1" />
@@ -518,7 +518,7 @@ const AdminEventAttendance = () => {
</Button> </Button>
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <td className="px-4 py-3 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'} {rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'}
</td> </td>
</tr> </tr>
@@ -526,7 +526,7 @@ const AdminEventAttendance = () => {
) : ( ) : (
<tr> <tr>
<td colSpan="6" className="px-4 py-12 text-center"> <td colSpan="6" className="px-4 py-12 text-center">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'} {searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'}
</p> </p>
</td> </td>

View File

@@ -137,7 +137,7 @@ const AdminEvents = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Management Event Management
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create and manage community events. Create and manage community events.
</p> </p>
</div> </div>
@@ -150,7 +150,7 @@ const AdminEvents = () => {
resetForm(); resetForm();
setEditingEvent(null); setEditingEvent(null);
}} }}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-6" className="btn-lavender "
data-testid="create-event-button" data-testid="create-event-button"
> >
<Plus className="mr-2 h-5 w-5" /> <Plus className="mr-2 h-5 w-5" />
@@ -158,7 +158,7 @@ const AdminEvents = () => {
</Button> </Button>
</DialogTrigger> </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> <DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{editingEvent ? 'Edit Event' : 'Create New Event'} {editingEvent ? 'Edit Event' : 'Create New Event'}
@@ -174,7 +174,7 @@ const AdminEvents = () => {
value={formData.title} value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })} onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required required
className="border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -186,7 +186,7 @@ const AdminEvents = () => {
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4} rows={4}
className="w-full border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-lg p-3" className="w-full border-2 border-[var(--neutral-800)] bg-background focus:border-brand-purple rounded-lg p-3"
/> />
</div> </div>
@@ -200,7 +200,7 @@ const AdminEvents = () => {
value={formData.start_at} value={formData.start_at}
onChange={(e) => setFormData({ ...formData, start_at: e.target.value })} onChange={(e) => setFormData({ ...formData, start_at: e.target.value })}
required required
className="border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -213,7 +213,7 @@ const AdminEvents = () => {
value={formData.end_at} value={formData.end_at}
onChange={(e) => setFormData({ ...formData, end_at: e.target.value })} onChange={(e) => setFormData({ ...formData, end_at: e.target.value })}
required required
className="border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
</div> </div>
@@ -226,7 +226,7 @@ const AdminEvents = () => {
value={formData.location} value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })} onChange={(e) => setFormData({ ...formData, location: e.target.value })}
required required
className="border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -239,7 +239,7 @@ const AdminEvents = () => {
value={formData.capacity} value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: e.target.value })} onChange={(e) => setFormData({ ...formData, capacity: e.target.value })}
placeholder="Leave empty for unlimited" placeholder="Leave empty for unlimited"
className="border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
@@ -249,7 +249,7 @@ const AdminEvents = () => {
id="published" id="published"
checked={formData.published} checked={formData.published}
onChange={(e) => setFormData({ ...formData, published: e.target.checked })} onChange={(e) => setFormData({ ...formData, published: e.target.checked })}
className="w-4 h-4 text-[var(--purple-lavender)] border-[var(--neutral-800)] rounded focus:ring-[var(--purple-lavender)]" 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" }}> <label htmlFor="published" className="text-sm font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Publish event (make visible to members) Publish event (make visible to members)
@@ -267,7 +267,7 @@ const AdminEvents = () => {
</Button> </Button>
<Button <Button
type="submit" 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'} {editingEvent ? 'Update Event' : 'Create Event'}
</Button> </Button>
@@ -281,7 +281,7 @@ const AdminEvents = () => {
{/* Events List */} {/* Events List */}
{loading ? ( {loading ? (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
</div> </div>
) : events.length > 0 ? ( ) : events.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -294,11 +294,12 @@ const AdminEvents = () => {
{/* Event Header */} {/* Event Header */}
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg"> <div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-[var(--purple-lavender)]" /> <Calendar className="h-6 w-6 text-brand-purple " />
</div> </div>
<Badge <Badge
className={`${event.published className={`${event.published
? 'bg-[var(--green-light)] text-white' ? 'border-transparent bg-[var(--green-light)] text-white hover:bg-[var(--green-forest)]'
: 'bg-gray-400 text-white' : 'bg-gray-400 text-white'
} px-3 py-1 rounded-full`} } px-3 py-1 rounded-full`}
> >
@@ -312,13 +313,13 @@ const AdminEvents = () => {
</h3> </h3>
{event.description && ( {event.description && (
<p className="text-[var(--purple-lavender)] mb-4 line-clamp-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-4 line-clamp-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{event.description} {event.description}
</p> </p>
)} )}
<div className="space-y-2 mb-4"> <div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="flex items-center gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span> <span>
{new Date(event.start_at).toLocaleDateString()} at{' '} {new Date(event.start_at).toLocaleDateString()} at{' '}
@@ -328,11 +329,11 @@ const AdminEvents = () => {
})} })}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="flex items-center gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<MapPin className="h-4 w-4" /> <MapPin className="h-4 w-4" />
<span className="truncate">{event.location}</span> <span className="truncate">{event.location}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="flex items-center gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
<span>{event.rsvp_count || 0} attending</span> <span>{event.rsvp_count || 0} attending</span>
</div> </div>
@@ -345,7 +346,7 @@ const AdminEvents = () => {
onClick={() => navigate(`/admin/events/${event.id}/attendance`)} onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full border-[var(--green-light)] text-[var(--green-light)] hover:bg-[var(--green-light)] hover:text-white" className="w-full border-[var(--green-light)] text-[var(--green-light)] hover:bg-[var(--green-light)] hover:text-white dark:hover:text-background"
data-testid={`mark-attendance-${event.id}`} data-testid={`mark-attendance-${event.id}`}
> >
<Users className="h-4 w-4 mr-2" /> <Users className="h-4 w-4 mr-2" />
@@ -358,7 +359,7 @@ const AdminEvents = () => {
onClick={() => togglePublish(event)} onClick={() => togglePublish(event)}
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--purple-lavender)] hover:text-white" className="flex-1 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white dark:hover:bg-brand-lavender dark:hover:text-background"
data-testid={`toggle-publish-${event.id}`} data-testid={`toggle-publish-${event.id}`}
> >
{event.published ? ( {event.published ? (
@@ -377,7 +378,7 @@ const AdminEvents = () => {
onClick={() => handleEdit(event)} onClick={() => handleEdit(event)}
variant="outline" variant="outline"
size="sm" size="sm"
className="border-gray-400 text-gray-600 hover:bg-gray-400 hover:text-white" className="border-gray-400 text-gray-600 dark:text-gray-400 hover:bg-gray-400 dark:hover:text-background hover:text-white"
data-testid={`edit-event-${event.id}`} data-testid={`edit-event-${event.id}`}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
@@ -402,12 +403,12 @@ const AdminEvents = () => {
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Events Yet No Events Yet
</h3> </h3>
<p className="text-[var(--purple-lavender)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create your first event to get started! Create your first event to get started!
</p> </p>
<Button <Button
onClick={() => setIsCreateDialogOpen(true)} 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" /> <Plus className="mr-2 h-5 w-5" />
Create Event Create Event

View File

@@ -147,7 +147,7 @@ const AdminFinancials = () => {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[var(--purple-lavender)]">Loading financial reports...</p> <p className="text-brand-purple ">Loading financial reports...</p>
</div> </div>
); );
} }
@@ -160,14 +160,14 @@ const AdminFinancials = () => {
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Financial Reports Management Financial Reports Management
</h1> </h1>
<p className="text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage annual financial reports Manage annual financial reports
</p> </p>
</div> </div>
{hasPermission('financials.create') && ( {hasPermission('financials.create') && (
<Button <Button
onClick={handleCreate} onClick={handleCreate}
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2" className="btn-lavender flex items-center gap-2"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Report Add Report
@@ -179,9 +179,9 @@ const AdminFinancials = () => {
{reports.length === 0 ? ( {reports.length === 0 ? (
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<TrendingUp className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" /> <TrendingUp className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
<p className="text-[var(--purple-lavender)] text-lg mb-4">No financial reports yet</p> <p className="text-brand-purple text-lg mb-4">No financial reports yet</p>
{hasPermission('financials.create') && ( {hasPermission('financials.create') && (
<Button onClick={handleCreate} className="bg-[var(--purple-lavender)] text-white"> <Button onClick={handleCreate} className="bg-brand-purple text-white">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create First Report Create First Report
</Button> </Button>
@@ -191,24 +191,23 @@ const AdminFinancials = () => {
<div className="space-y-4"> <div className="space-y-4">
{reports.map(report => ( {reports.map(report => (
<Card key={report.id} className="p-6"> <Card key={report.id} className="p-6">
<div className="flex items-center gap-6"> <div className="flex items-center gap-3">
<div className="bg-gradient-to-br from-[var(--purple-lavender)] to-[var(--purple-ink)] p-4 rounded-xl text-white min-w-[100px] text-center"> <div className="bg-light-lavender p-3 rounded-xl self-center">
<DollarSign className="h-6 w-6 mx-auto mb-1" /> <DollarSign className="size-8 " />
<div className="text-2xl font-bold">{report.year}</div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
{report.title} {report.title}
</h3> </h3>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant="outline" className="border-[var(--purple-lavender)] text-[var(--purple-lavender)]"> <Badge variant="outline" className="border-brand-purple text-brand-purple ">
{report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()} {report.document_type === 'google_drive' ? 'Google Drive' : report.document_type.toUpperCase()}
</Badge> </Badge>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => window.open(report.document_url, '_blank')} onClick={() => window.open(report.document_url, '_blank')}
className="text-[var(--purple-lavender)] hover:text-[var(--purple-muted)]" className="text-brand-purple hover:text-[var(--purple-muted)]"
> >
<ExternalLink className="h-4 w-4 mr-1" /> <ExternalLink className="h-4 w-4 mr-1" />
View View
@@ -222,17 +221,17 @@ const AdminFinancials = () => {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleEdit(report)} onClick={() => handleEdit(report)}
className="border-[var(--purple-lavender)] text-[var(--purple-lavender)]" className="border-brand-purple text-brand-purple "
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)} )}
{hasPermission('financials.delete') && ( {hasPermission('financials.delete') && (
<Button <Button
variant="outline" variant="outline-destructive"
size="sm" size="sm"
onClick={() => handleDelete(report)} onClick={() => handleDelete(report)}
className="border-red-500 text-red-500 hover:bg-red-50" className=""
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -313,12 +312,12 @@ const AdminFinancials = () => {
required={!selectedReport} required={!selectedReport}
/> />
{uploadedFile && ( {uploadedFile && (
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Selected: {uploadedFile.name} Selected: {uploadedFile.name}
</p> </p>
)} )}
{selectedReport && !uploadedFile && ( {selectedReport && !uploadedFile && (
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Current file will be kept if no new file is selected Current file will be kept if no new file is selected
</p> </p>
)} )}
@@ -333,7 +332,7 @@ const AdminFinancials = () => {
placeholder="https://docs.google.com/... or https://example.com/file.pdf" placeholder="https://docs.google.com/... or https://example.com/file.pdf"
required required
/> />
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.) Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
</p> </p>
</div> </div>
@@ -350,7 +349,7 @@ const AdminFinancials = () => {
</Button> </Button>
<Button <Button
type="submit" type="submit"
className="bg-[var(--purple-lavender)] text-white" className="bg-brand-purple text-white"
disabled={submitting} disabled={submitting}
> >
{submitting ? 'Saving...' : selectedReport ? 'Update' : 'Create'} {submitting ? 'Saving...' : selectedReport ? 'Update' : 'Create'}

View File

@@ -156,7 +156,7 @@ const AdminGallery = () => {
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Gallery Management Event Gallery Management
</h1> </h1>
<p className="text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload and manage photos for event galleries Upload and manage photos for event galleries
</p> </p>
</div> </div>
@@ -184,19 +184,19 @@ const AdminGallery = () => {
{/* Empty State Message */} {/* Empty State Message */}
{events.length === 0 && ( {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"> <div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-[var(--purple-lavender)] flex-shrink-0 mt-0.5" /> <AlertCircle className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<h4 className="text-sm font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-sm font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
No Events Available No Events Available
</h4> </h4>
<p className="text-sm text-[var(--purple-lavender)] mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You need to create an event before uploading gallery images. Events help organize photos by occasion. You need to create an event before uploading gallery images. Events help organize photos by occasion.
</p> </p>
<Link to="/admin/events"> <Link to="/admin/events">
<Button <Button
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white rounded-xl text-sm" className="btn-lavender text-sm"
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
<Calendar className="h-4 w-4 mr-2" /> <Calendar className="h-4 w-4 mr-2" />
@@ -221,7 +221,7 @@ const AdminGallery = () => {
<Button <Button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploading} disabled={uploading}
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white rounded-xl" className="btn-lavender "
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
{uploading ? ( {uploading ? (
@@ -236,7 +236,7 @@ const AdminGallery = () => {
</> </>
)} )}
</Button> </Button>
<p className="text-sm text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You can select multiple images. Max {formatFileSize(maxFileSize)} per image. You can select multiple images. Max {formatFileSize(maxFileSize)} per image.
</p> </p>
</div> </div>
@@ -251,7 +251,7 @@ const AdminGallery = () => {
<h2 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Gallery Images Gallery Images
</h2> </h2>
<Badge className="bg-[var(--purple-lavender)] text-white px-3 py-1"> <Badge variant="purple" className=" px-3 py-1">
{galleryImages.length} {galleryImages.length === 1 ? 'image' : 'images'} {galleryImages.length} {galleryImages.length === 1 ? 'image' : 'images'}
</Badge> </Badge>
</div> </div>
@@ -275,7 +275,7 @@ const AdminGallery = () => {
<Button <Button
onClick={() => openEditCaption(image)} onClick={() => openEditCaption(image)}
size="sm" size="sm"
className="bg-background/90 hover:bg-background text-[var(--purple-ink)] rounded-lg" className="bg-background/90 hover:bg-background text-[var(--purple-ink)] dark:text-[#ddd8eb] rounded-lg"
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
<Edit className="h-4 w-4 mr-1" /> <Edit className="h-4 w-4 mr-1" />
@@ -299,7 +299,7 @@ const AdminGallery = () => {
{/* Caption Preview */} {/* Caption Preview */}
{image.caption && ( {image.caption && (
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-[var(--purple-lavender)] line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{image.caption} {image.caption}
</p> </p>
</div> </div>
@@ -307,7 +307,7 @@ const AdminGallery = () => {
{/* File Size */} {/* File Size */}
<div className="mt-1"> <div className="mt-1">
<p className="text-xs text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatFileSize(image.file_size_bytes)} {formatFileSize(image.file_size_bytes)}
</p> </p>
</div> </div>
@@ -320,7 +320,7 @@ const AdminGallery = () => {
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Images Yet No Images Yet
</h3> </h3>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload images to create a gallery for this event. Upload images to create a gallery for this event.
</p> </p>
</div> </div>
@@ -367,14 +367,14 @@ const AdminGallery = () => {
<Button <Button
onClick={() => setEditingCaption(null)} onClick={() => setEditingCaption(null)}
variant="outline" variant="outline"
className="border-[var(--neutral-800)] text-[var(--purple-lavender)] rounded-xl" className="border-[var(--neutral-800)] text-brand-purple rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleUpdateCaption} onClick={handleUpdateCaption}
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white rounded-xl" className="bg-brand-purple hover:bg-[var(--purple-ink)] text-white rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
Save Caption Save Caption

View File

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

View File

@@ -1,10 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { import {
@@ -14,22 +13,30 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
import { toast } from 'sonner'; 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 PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog'; import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog'; import InviteMemberDialog from '../../components/InviteMemberDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard'; import WordPressImportWizard from '../../components/WordPressImportWizard';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { useMembers } from '../../hooks/use-users';
const AdminMembers = () => { const AdminMembers = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [users, setUsers] = useState([]); const {
const [filteredUsers, setFilteredUsers] = useState([]); users,
const [loading, setLoading] = useState(true); filteredUsers,
const [searchQuery, setSearchQuery] = useState(''); loading,
const [statusFilter, setStatusFilter] = useState('active'); searchQuery,
setSearchQuery,
filterValue: statusFilter,
setFilterValue: setStatusFilter,
refetch,
} = useMembers();
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null); const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
const [statusChanging, setStatusChanging] = useState(null); const [statusChanging, setStatusChanging] = useState(null);
@@ -40,53 +47,13 @@ const AdminMembers = () => {
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
useEffect(() => {
fetchMembers();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, statusFilter]);
const fetchMembers = async () => {
try {
const response = await api.get('/admin/users');
// Filter to only members
const members = response.data.filter(user => user.role === 'member');
setUsers(members);
} catch (error) {
toast.error('Failed to fetch members');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (statusFilter && statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const handleActivatePayment = (user) => { const handleActivatePayment = (user) => {
setSelectedUserForPayment(user); setSelectedUserForPayment(user);
setPaymentDialogOpen(true); setPaymentDialogOpen(true);
}; };
const handlePaymentSuccess = () => { const handlePaymentSuccess = () => {
fetchMembers(); // Refresh list refetch(); // Refresh list
}; };
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => { const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
@@ -107,7 +74,7 @@ const AdminMembers = () => {
try { try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus }); await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success('Member status updated successfully'); toast.success('Member status updated successfully');
fetchMembers(); // Refresh list refetch(); // Refresh list
} catch (error) { } catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update status'); toast.error(error.response?.data?.detail || 'Failed to update status');
} finally { } finally {
@@ -199,27 +166,6 @@ const AdminMembers = () => {
}; };
}; };
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Pending 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' },
active: { label: 'Active', className: 'bg-[var(--green-light)] text-white' },
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' },
canceled: { label: 'Canceled', className: 'bg-red-100 text-red-700' },
expired: { label: 'Expired', className: 'bg-red-500 text-white' },
abandoned: { label: 'Abandoned', className: 'bg-gray-300 text-gray-600' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
const getReminderInfo = (user) => { const getReminderInfo = (user) => {
const emailReminders = user.email_verification_reminders_sent || 0; const emailReminders = user.email_verification_reminders_sent || 0;
const eventReminders = user.event_attendance_reminders_sent || 0; const eventReminders = user.event_attendance_reminders_sent || 0;
@@ -243,12 +189,12 @@ const AdminMembers = () => {
return ( return (
<> <>
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-start mb-4"> <div className="flex flex-col md:flex-row justify-between items-start mb-4">
<div> <div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Management Members Management
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple dark:text-brand-lavender" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage paying members and their subscriptions. Manage paying members and their subscriptions.
</p> </p>
</div> </div>
@@ -257,7 +203,7 @@ const AdminMembers = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white rounded-xl h-12 px-6" className="btn-util-purple "
disabled={exporting} disabled={exporting}
> >
{exporting ? ( {exporting ? (
@@ -288,7 +234,7 @@ const AdminMembers = () => {
{hasPermission('users.import') && ( {hasPermission('users.import') && (
<Button <Button
onClick={() => setImportDialogOpen(true)} onClick={() => setImportDialogOpen(true)}
className="bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white rounded-xl h-12 px-6" className="btn-util-green "
> >
<Upload className="h-5 w-5 mr-2" /> <Upload className="h-5 w-5 mr-2" />
Import Import
@@ -298,7 +244,7 @@ const AdminMembers = () => {
{hasPermission('users.invite') && ( {hasPermission('users.invite') && (
<Button <Button
onClick={() => setInviteDialogOpen(true)} onClick={() => setInviteDialogOpen(true)}
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white rounded-xl h-12 px-6" className="btn-util-purple "
> >
<Mail className="h-5 w-5 mr-2" /> <Mail className="h-5 w-5 mr-2" />
Invite Member Invite Member
@@ -308,7 +254,7 @@ const AdminMembers = () => {
{hasPermission('users.create') && ( {hasPermission('users.create') && (
<Button <Button
onClick={() => setCreateDialogOpen(true)} onClick={() => setCreateDialogOpen(true)}
className="bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white rounded-xl h-12 px-6" className="btn-util-green "
> >
<UserPlus className="h-5 w-5 mr-2" /> <UserPlus className="h-5 w-5 mr-2" />
Create Member Create Member
@@ -319,43 +265,52 @@ const AdminMembers = () => {
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p> Quick Overview
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> </div>
{users.length} <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
</p> <StatCard
</Card> title="Active"
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> value={users.filter(u => u.status === 'active').length}
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p> icon={CheckCircle}
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> iconBgClass="text-[var(--green-light)]"
{users.filter(u => u.status === 'active').length} dataTestId="stat-active-members"
</p> />
</Card> <StatCard
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> title="Payment Pending"
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p> value={users.filter(u => u.status === 'payment_pending').length}
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> icon={CreditCard}
{users.filter(u => u.status === 'payment_pending').length} iconBgClass="text-brand-light-orange"
</p> dataTestId="stat-payment-pending-members"
</Card> />
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <StatCard
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Inactive</p> title="Inactive"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={users.filter(u => u.status === 'inactive').length}
{users.filter(u => u.status === 'inactive').length} icon={CircleMinus}
</p> iconBgClass=" text-brand-pink"
</Card> dataTestId="stat-inactive-members"
/>
<StatCard
title="Total Members"
value={users.length}
icon={Users}
iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
dataTestId="stat-total-members"
/>
</div>
</div> </div>
{/* Filters */} {/* Filters */}
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[var(--purple-lavender)]" /> <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input <Input
placeholder="Search by name or email..." placeholder="Search by name or email..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="search-members-input" data-testid="search-members-input"
/> />
</div> </div>
@@ -368,11 +323,9 @@ const AdminMembers = () => {
<SelectItem value="active">Active</SelectItem> <SelectItem value="active">Active</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem> <SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem> <SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem> <SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem> <SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem> <SelectItem value="expired">Expired</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -381,11 +334,14 @@ const AdminMembers = () => {
{/* Members List */} {/* Members List */}
{loading ? ( {loading ? (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading members...</p> <p className="text-brand-purple dark:text-brand-lavender " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading members...</p>
</div> </div>
) : filteredUsers.length > 0 ? ( ) : filteredUsers.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{filteredUsers.map((user) => ( {filteredUsers.map((user) => {
const joinedDate = user.created_at;
const memberDate = user.member_since;
return (
<Card <Card
key={user.id} key={user.id}
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow" className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow"
@@ -404,12 +360,13 @@ const AdminMembers = () => {
<h3 className="text-xl font-semibold text-[var(--purple-ink)] " style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] " style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</h3> </h3>
{getStatusBadge(user.status)} <StatusBadge status={user.status} />
</div> </div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple dark:text-brand-lavender " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p> <p>Email: {user.email}</p>
<p>Phone: {user.phone}</p> <p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p> <p>Registered: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
<p>Member Since: {memberDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
{user.referred_by_member_name && ( {user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p> <p>Referred by: {user.referred_by_member_name}</p>
)} )}
@@ -432,7 +389,7 @@ const AdminMembers = () => {
)} )}
</span> </span>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <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 && ( {reminderInfo.emailReminders > 0 && (
<p> <p>
<Mail className="inline h-3 w-3 mr-1" /> <Mail className="inline h-3 w-3 mr-1" />
@@ -459,7 +416,7 @@ const AdminMembers = () => {
)} )}
</div> </div>
{reminderInfo.lastReminderAt && ( {reminderInfo.lastReminderAt && (
<p className="mt-2 text-xs text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <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' })} Last reminder: {new Date(reminderInfo.lastReminderAt).toLocaleDateString()} at {new Date(reminderInfo.lastReminderAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p> </p>
)} )}
@@ -478,7 +435,7 @@ const AdminMembers = () => {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--purple-lavender)] hover:text-white" className=""
> >
<Eye className="h-4 w-4 mr-1" /> <Eye className="h-4 w-4 mr-1" />
View Profile View Profile
@@ -500,7 +457,7 @@ const AdminMembers = () => {
{/* Status Management */} {/* Status Management */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-[var(--purple-lavender)] whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-sm text-brand-purple dark:text-brand-lavender whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Change Status: Change Status:
</span> </span>
<Select <Select
@@ -523,7 +480,8 @@ const AdminMembers = () => {
</div> </div>
</div> </div>
</Card> </Card>
))} );
})}
</div> </div>
) : ( ) : (
<div className="text-center py-20"> <div className="text-center py-20">
@@ -531,7 +489,7 @@ const AdminMembers = () => {
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Members Found No Members Found
</h3> </h3>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery || statusFilter !== 'all' {searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters' ? 'Try adjusting your filters'
: 'No members yet'} : 'No members yet'}
@@ -560,19 +518,19 @@ const AdminMembers = () => {
<CreateMemberDialog <CreateMemberDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
onSuccess={fetchMembers} onSuccess={refetch}
/> />
<InviteStaffDialog <InviteMemberDialog
open={inviteDialogOpen} open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen} onOpenChange={setInviteDialogOpen}
onSuccess={fetchMembers} onSuccess={refetch}
/> />
<WordPressImportWizard <WordPressImportWizard
open={importDialogOpen} open={importDialogOpen}
onOpenChange={setImportDialogOpen} onOpenChange={setImportDialogOpen}
onSuccess={fetchMembers} onSuccess={refetch}
/> />
</> </>
); );

View File

@@ -175,7 +175,7 @@ const AdminNewsletters = () => {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[var(--purple-lavender)]">Loading newsletters...</p> <p className="text-brand-purple ">Loading newsletters...</p>
</div> </div>
); );
} }
@@ -188,14 +188,14 @@ const AdminNewsletters = () => {
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Newsletter Management Newsletter Management
</h1> </h1>
<p className="text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create and manage newsletter archive Create and manage newsletter archive
</p> </p>
</div> </div>
{hasPermission('newsletters.create') && ( {hasPermission('newsletters.create') && (
<Button <Button
onClick={handleCreate} onClick={handleCreate}
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2" className="btn-light-lavender flex items-center gap-2"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Newsletter Add Newsletter
@@ -207,9 +207,9 @@ const AdminNewsletters = () => {
{newsletters.length === 0 ? ( {newsletters.length === 0 ? (
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<FileText className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" /> <FileText className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
<p className="text-[var(--purple-lavender)] text-lg mb-4">No newsletters yet</p> <p className="text-brand-purple text-lg mb-4">No newsletters yet</p>
{hasPermission('newsletters.create') && ( {hasPermission('newsletters.create') && (
<Button onClick={handleCreate} className="bg-[var(--purple-lavender)] text-white"> <Button onClick={handleCreate} className="bg-brand-purple text-white">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create First Newsletter Create First Newsletter
</Button> </Button>
@@ -223,29 +223,32 @@ const AdminNewsletters = () => {
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
{year} {year}
</h2> </h2>
<div className="grid gap-4"> <div className="grid gap-3">
{groupedNewsletters[year].map(newsletter => ( {groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6"> <Card key={newsletter.id} className="p-6">
<div className="flex items-start justify-between "> <div className="flex items-start justify-between ">
<div className="bg-light-lavender p-3 mr-4 rounded-xl self-center">
<FileText className="size-8 " />
</div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
{newsletter.title} {newsletter.title}
</h3> </h3>
{newsletter.description && ( {newsletter.description && (
<p className="text-[var(--purple-lavender)] mb-3">{newsletter.description}</p> <p className="text-brand-purple mb-3">{newsletter.description}</p>
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]"> <Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]">
{formatDate(newsletter.published_date)} {formatDate(newsletter.published_date)}
</Badge> </Badge>
<Badge variant="outline" className="border-[var(--purple-lavender)] text-[var(--purple-lavender)]"> <Badge variant="outline" className="border-brand-purple text-brand-purple ">
{newsletter.document_type === 'upload' ? 'PDF Upload' : 'Link'} {newsletter.document_type === 'upload' ? 'PDF Upload' : 'Link'}
</Badge> </Badge>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => window.open(newsletter.document_url, '_blank')} onClick={() => window.open(newsletter.document_url, '_blank')}
className="text-[var(--purple-lavender)] hover:text-[var(--purple-muted)]" className="text-brand-purple hover:text-[var(--purple-muted)]"
> >
<ExternalLink className="h-4 w-4 mr-1" /> <ExternalLink className="h-4 w-4 mr-1" />
View View
@@ -259,17 +262,17 @@ const AdminNewsletters = () => {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleEdit(newsletter)} onClick={() => handleEdit(newsletter)}
className="border-[var(--purple-lavender)] text-[var(--purple-lavender)]" className="border-brand-purple text-brand-purple "
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)} )}
{hasPermission('newsletters.delete') && ( {hasPermission('newsletters.delete') && (
<Button <Button
variant="outline" variant="outline-destructive"
size="sm" size="sm"
onClick={() => handleDelete(newsletter)} onClick={() => handleDelete(newsletter)}
className="border-red-500 text-red-500 hover:bg-red-50" className=""
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -287,7 +290,7 @@ const AdminNewsletters = () => {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{selectedNewsletter ? 'Edit Newsletter' : 'Add Newsletter'} {selectedNewsletter ? 'Edit Newsletter' : 'Add Newsletter'}
@@ -361,12 +364,12 @@ const AdminNewsletters = () => {
required={!selectedNewsletter} required={!selectedNewsletter}
/> />
{uploadedFile && ( {uploadedFile && (
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Selected: {uploadedFile.name} Selected: {uploadedFile.name}
</p> </p>
)} )}
{selectedNewsletter && !uploadedFile && ( {selectedNewsletter && !uploadedFile && (
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Current file will be kept if no new file is selected Current file will be kept if no new file is selected
</p> </p>
)} )}
@@ -381,7 +384,7 @@ const AdminNewsletters = () => {
placeholder="https://docs.google.com/document/d/... or https://example.com/file.pdf" placeholder="https://docs.google.com/document/d/... or https://example.com/file.pdf"
required required
/> />
<p className="text-sm text-[var(--purple-lavender)] mt-1"> <p className="text-sm text-brand-purple mt-1">
Paste the shareable link to your document (Google Docs, Dropbox, PDF URL, etc.) Paste the shareable link to your document (Google Docs, Dropbox, PDF URL, etc.)
</p> </p>
</div> </div>
@@ -398,7 +401,7 @@ const AdminNewsletters = () => {
</Button> </Button>
<Button <Button
type="submit" type="submit"
className="bg-[var(--purple-lavender)] text-white" className="bg-brand-purple text-white"
disabled={submitting} disabled={submitting}
> >
{submitting ? 'Saving...' : selectedNewsletter ? 'Update' : 'Create'} {submitting ? 'Saving...' : selectedNewsletter ? 'Update' : 'Create'}

View File

@@ -188,7 +188,7 @@ const AdminPermissions = () => {
const getRoleBadge = (role) => { const getRoleBadge = (role) => {
const config = { const config = {
admin: { label: 'Admin', color: 'bg-[var(--green-light)]', icon: Shield }, admin: { label: 'Admin', color: 'bg-[var(--green-light)]', icon: Shield },
member: { label: 'Member', color: 'bg-[var(--purple-lavender)]', icon: Shield }, member: { label: 'Member', color: 'bg-brand-purple ', icon: Shield },
guest: { label: 'Guest', color: 'bg-gray-400', icon: Shield } guest: { label: 'Guest', color: 'bg-gray-400', icon: Shield }
}; };
@@ -206,7 +206,7 @@ const AdminPermissions = () => {
if (loading) { if (loading) {
return ( return (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading permissions... Loading permissions...
</p> </p>
</div> </div>
@@ -220,7 +220,7 @@ const AdminPermissions = () => {
<h2 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-3xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Access Denied Access Denied
</h2> </h2>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You don't have permission to manage role permissions. You don't have permission to manage role permissions.
</p> </p>
<p className="text-sm text-gray-500 mt-2"> <p className="text-sm text-gray-500 mt-2">
@@ -236,7 +236,7 @@ const AdminPermissions = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Permission Management Permission Management
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Configure granular permissions for each role. Superadmin always has all permissions. Configure granular permissions for each role. Superadmin always has all permissions.
</p> </p>
</div> </div>
@@ -260,7 +260,7 @@ const AdminPermissions = () => {
{/* Stats */} {/* Stats */}
<div className="grid md:grid-cols-3 gap-4 mb-8"> <div className="grid md:grid-cols-3 gap-4 mb-8">
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Permissions Total Permissions
</p> </p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -268,7 +268,7 @@ const AdminPermissions = () => {
</p> </p>
</Card> </Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Assigned Assigned
</p> </p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -276,7 +276,7 @@ const AdminPermissions = () => {
</p> </p>
</Card> </Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Modules Modules
</p> </p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -300,21 +300,21 @@ const AdminPermissions = () => {
checked={isModuleFullySelected(role, module)} checked={isModuleFullySelected(role, module)}
onCheckedChange={() => toggleModule(role, module)} onCheckedChange={() => toggleModule(role, module)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="h-6 w-6 border-2 border-[var(--purple-lavender)] data-[state=checked]:bg-[var(--purple-lavender)]" className="h-6 w-6 border-2 border-brand-purple data-[state=checked]:bg-brand-purple "
/> />
<div> <div>
<h3 className="text-xl font-semibold text-[var(--purple-ink)] capitalize" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] capitalize" style={{ fontFamily: "'Inter', sans-serif" }}>
{module} {module}
</h3> </h3>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{getModuleProgress(role, module)} permissions {getModuleProgress(role, module)} permissions
</p> </p>
</div> </div>
</div> </div>
{expandedModules[module] ? ( {expandedModules[module] ? (
<ChevronUp className="h-6 w-6 text-[var(--purple-lavender)]" /> <ChevronUp className="h-6 w-6 text-brand-purple " />
) : ( ) : (
<ChevronDown className="h-6 w-6 text-[var(--purple-lavender)]" /> <ChevronDown className="h-6 w-6 text-brand-purple " />
)} )}
</div> </div>
</div> </div>
@@ -331,13 +331,13 @@ const AdminPermissions = () => {
<Checkbox <Checkbox
checked={selectedPermissions[role].includes(perm.code)} checked={selectedPermissions[role].includes(perm.code)}
onCheckedChange={() => togglePermission(role, perm.code)} onCheckedChange={() => togglePermission(role, perm.code)}
className="mt-1 h-5 w-5 border-2 border-[var(--purple-lavender)] data-[state=checked]:bg-[var(--purple-lavender)]" className="mt-1 h-5 w-5 border-2 border-brand-purple data-[state=checked]:bg-brand-purple "
/> />
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{perm.name} {perm.name}
</p> </p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{perm.description} {perm.description}
</p> </p>
<p className="text-xs text-gray-400 mt-1 font-mono"> <p className="text-xs text-gray-400 mt-1 font-mono">
@@ -357,7 +357,7 @@ const AdminPermissions = () => {
</Tabs> </Tabs>
{/* Superadmin Note */} {/* Superadmin Note */}
<Card className="p-6 bg-gradient-to-r from-[var(--purple-lavender)] to-[var(--purple-ink)] rounded-2xl border-none mb-8"> <Card className="p-6 bg-gradient-to-r from-brand-purple to-[var(--purple-ink)] rounded-2xl border-none mb-8">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<Lock className="h-6 w-6 text-white flex-shrink-0 mt-1" /> <Lock className="h-6 w-6 text-white flex-shrink-0 mt-1" />
<div className="text-white"> <div className="text-white">
@@ -392,7 +392,7 @@ const AdminPermissions = () => {
<AlertDialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <AlertDialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Confirm Permission Changes Confirm Permission Changes
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <AlertDialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Are you sure you want to update permissions for <span className="font-semibold capitalize">{selectedRole}</span>? Are you sure you want to update permissions for <span className="font-semibold capitalize">{selectedRole}</span>?
This will immediately affect all users with this role. This will immediately affect all users with this role.
</AlertDialogDescription> </AlertDialogDescription>

View File

@@ -23,6 +23,7 @@ import {
Search, Search,
DollarSign DollarSign
} from 'lucide-react'; } from 'lucide-react';
import StatusBadge from '@/components/StatusBadge';
const AdminPlans = () => { const AdminPlans = () => {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -134,14 +135,14 @@ const AdminPlans = () => {
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Plans Subscription Plans
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage membership plans and pricing. Manage membership plans and pricing.
</p> </p>
</div> </div>
{hasPermission('subscriptions.plans') && ( {hasPermission('subscriptions.plans') && (
<Button <Button
onClick={handleCreatePlan} onClick={handleCreatePlan}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-6" className="btn-lavender "
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create Plan Create Plan
@@ -153,25 +154,25 @@ const AdminPlans = () => {
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <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)]"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Plans</p> <p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Plans</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{plans.length} {plans.length}
</p> </p>
</Card> </Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Plans</p> <p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Plans</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{plans.filter(p => p.active).length} {plans.filter(p => p.active).length}
</p> </p>
</Card> </Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Subscribers</p> <p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Subscribers</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{plans.reduce((sum, p) => sum + (p.subscriber_count || 0), 0)} {plans.reduce((sum, p) => sum + (p.subscriber_count || 0), 0)}
</p> </p>
</Card> </Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Revenue (Annual Est.)</p> <p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Revenue (Annual Est.)</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice( {formatPrice(
plans.reduce((sum, p) => { plans.reduce((sum, p) => {
@@ -189,12 +190,12 @@ const AdminPlans = () => {
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[var(--purple-lavender)]" /> <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input <Input
placeholder="Search plans..." placeholder="Search plans..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
<Select value={activeFilter} onValueChange={setActiveFilter}> <Select value={activeFilter} onValueChange={setActiveFilter}>
@@ -213,7 +214,7 @@ const AdminPlans = () => {
{/* Plans Grid */} {/* Plans Grid */}
{loading ? ( {loading ? (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
</div> </div>
) : filteredPlans.length > 0 ? ( ) : filteredPlans.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -221,7 +222,7 @@ const AdminPlans = () => {
<Card <Card
key={plan.id} key={plan.id}
className={`p-6 bg-background rounded-2xl border-2 transition-all hover:shadow-lg ${plan.active className={`p-6 bg-background rounded-2xl border-2 transition-all hover:shadow-lg ${plan.active
? 'border-[var(--neutral-800)] hover:border-[var(--purple-lavender)]' ? 'border-[var(--neutral-800)] hover:border-brand-purple '
: 'border-gray-400 opacity-60' : 'border-gray-400 opacity-60'
}`} }`}
> >
@@ -236,13 +237,13 @@ const AdminPlans = () => {
{plan.active ? 'Active' : 'Inactive'} {plan.active ? 'Active' : 'Inactive'}
</Badge> </Badge>
{plan.subscriber_count > 0 && ( {plan.subscriber_count > 0 && (
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]"> <Badge className="bg-[var(--neutral-800)] hover:text-white text-[var(--purple-ink)]">
<Users className="h-3 w-3 mr-1" /> <Users className="h-3 w-3 mr-1" />
{plan.subscriber_count} {plan.subscriber_count}
</Badge> </Badge>
)} )}
{plan.custom_cycle_enabled && ( {plan.custom_cycle_enabled && (
<Badge className="bg-[var(--purple-lavender)] text-white"> <Badge className="bg-brand-purple text-white">
Custom Dates Custom Dates
</Badge> </Badge>
)} )}
@@ -260,7 +261,7 @@ const AdminPlans = () => {
{/* Description */} {/* Description */}
{plan.description && ( {plan.description && (
<p className="text-sm text-[var(--purple-lavender)] mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{plan.description} {plan.description}
</p> </p>
)} )}
@@ -272,16 +273,16 @@ const AdminPlans = () => {
{formatPrice(plan.minimum_price_cents || plan.price_cents)} {formatPrice(plan.minimum_price_cents || plan.price_cents)}
</div> </div>
{plan.suggested_price_cents && plan.suggested_price_cents > plan.minimum_price_cents && ( {plan.suggested_price_cents && plan.suggested_price_cents > plan.minimum_price_cents && (
<div className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
(Suggested: {formatPrice(plan.suggested_price_cents)}) (Suggested: {formatPrice(plan.suggested_price_cents)})
</div> </div>
)} )}
</div> </div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{getBillingCycleLabel(plan.billing_cycle)} {getBillingCycleLabel(plan.billing_cycle)}
</p> </p>
{plan.custom_cycle_enabled && ( {plan.custom_cycle_enabled && (
<p className="text-xs text-[var(--purple-lavender)] font-mono mt-1"> <p className="text-xs text-brand-purple font-mono mt-1">
{formatCustomCycleDates(plan)} {formatCustomCycleDates(plan)}
</p> </p>
)} )}
@@ -294,7 +295,7 @@ const AdminPlans = () => {
onClick={() => handleEditPlan(plan)} onClick={() => handleEditPlan(plan)}
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--purple-lavender)] hover:text-white rounded-full" className="flex-1 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white rounded-full dark:hover:text-background"
> >
<Edit className="h-4 w-4 mr-1" /> <Edit className="h-4 w-4 mr-1" />
Edit Edit
@@ -303,7 +304,7 @@ const AdminPlans = () => {
onClick={() => handleDeleteClick(plan)} onClick={() => handleDeleteClick(plan)}
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1 border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-full" className="flex-1 border-red-500 text-red-500 hover:bg-red-500 dark:hover:bg-red-500/10 hover:text-white rounded-full"
disabled={plan.subscriber_count > 0} disabled={plan.subscriber_count > 0}
> >
<Trash2 className="h-4 w-4 mr-1" /> <Trash2 className="h-4 w-4 mr-1" />
@@ -314,7 +315,7 @@ const AdminPlans = () => {
{/* Warning for plans with subscribers */} {/* Warning for plans with subscribers */}
{plan.subscriber_count > 0 && ( {plan.subscriber_count > 0 && (
<p className="text-xs text-[var(--purple-lavender)] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-xs text-brand-purple mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Cannot delete plan with active subscribers Cannot delete plan with active subscribers
</p> </p>
)} )}
@@ -327,7 +328,7 @@ const AdminPlans = () => {
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Plans Found No Plans Found
</h3> </h3>
<p className="text-[var(--purple-lavender)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery || activeFilter !== 'all' {searchQuery || activeFilter !== 'all'
? 'Try adjusting your filters' ? 'Try adjusting your filters'
: 'Create your first subscription plan to get started'} : 'Create your first subscription plan to get started'}
@@ -359,7 +360,7 @@ const AdminPlans = () => {
<h2 className="text-xl sm:text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-xl sm:text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Delete Plan Delete Plan
</h2> </h2>
<p className="text-sm sm:text-base text-[var(--purple-lavender)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm sm:text-base text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Are you sure you want to delete "{planToDelete?.name}"? This action Are you sure you want to delete "{planToDelete?.name}"? This action
will deactivate the plan and it won't be available for new subscriptions. will deactivate the plan and it won't be available for new subscriptions.
</p> </p>

View File

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

View File

@@ -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 { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
@@ -12,72 +11,39 @@ import CreateStaffDialog from '../../components/CreateStaffDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog'; import InviteStaffDialog from '../../components/InviteStaffDialog';
import PendingInvitationsTable from '../../components/PendingInvitationsTable'; import PendingInvitationsTable from '../../components/PendingInvitationsTable';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX } from 'lucide-react'; import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX, ShieldIcon } from 'lucide-react';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { CircleMinus, CreditCard, Users } from 'lucide-react';
import { useStaff } from '../../hooks/use-users';
const AdminStaff = () => { const AdminStaff = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission, user } = useAuth(); const { hasPermission, user } = useAuth();
const [users, setUsers] = useState([]); const {
const [filteredUsers, setFilteredUsers] = useState([]); users,
const [loading, setLoading] = useState(true); filteredUsers,
const [searchQuery, setSearchQuery] = useState(''); loading,
const [roleFilter, setRoleFilter] = useState('all'); searchQuery,
setSearchQuery,
filterValue: roleFilter,
setFilterValue: setRoleFilter,
refetch,
} = useStaff({
initialFilter: 'all',
filterKey: 'role',
});
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState('staff-list'); const [activeTab, setActiveTab] = useState('staff-list');
// Staff roles (non-guest, non-member) - includes all admin-type roles
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
useEffect(() => {
fetchStaff();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, roleFilter]);
const fetchStaff = async () => {
try {
const response = await api.get('/admin/users');
// Filter to only staff roles
const staffUsers = response.data.filter(user =>
STAFF_ROLES.includes(user.role)
);
setUsers(staffUsers);
} catch (error) {
toast.error('Failed to fetch staff');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (roleFilter && roleFilter !== 'all') {
filtered = filtered.filter(user => user.role === roleFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const handleToggleStatus = async (userId, currentStatus) => { const handleToggleStatus = async (userId, currentStatus) => {
const newStatus = currentStatus === 'active' ? 'inactive' : 'active'; const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
try { try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus }); await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`); toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
fetchStaff(); // Refresh list refetch(); // Refresh list
} catch (error) { } catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update user status'); toast.error(error.response?.data?.detail || 'Failed to update user status');
} }
@@ -91,61 +57,33 @@ const AdminStaff = () => {
try { try {
await api.delete(`/admin/users/${userId}`); await api.delete(`/admin/users/${userId}`);
toast.success('User deleted successfully'); toast.success('User deleted successfully');
fetchStaff(); // Refresh list refetch(); // Refresh list
} catch (error) { } catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete user'); toast.error(error.response?.data?.detail || 'Failed to delete user');
} }
}; };
const getRoleBadge = (role) => {
const config = {
superadmin: { label: 'Superadmin', className: 'bg-[var(--purple-lavender)] text-white' },
admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' },
moderator: { label: 'Moderator', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' },
staff: { label: 'Staff', className: 'bg-gray-200 text-gray-700' },
media: { label: 'Media', className: 'bg-gray-400 text-white' }
};
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
return (
<Badge className={`${roleConfig.className} px-3 py-1 rounded-full text-sm`}>
<Shield className="h-3 w-3 mr-1 inline" />
{roleConfig.label}
</Badge>
);
};
const getStatusBadge = (status) => {
const config = {
active: { label: 'Active', className: 'bg-[var(--green-light)] text-white' },
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white ' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
return ( return (
<> <>
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-start mb-4"> <div className="flex flex-col md:flex-row justify-between items-start mb-4">
<div> <div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Staff Management Staff Management
</h1> </h1>
<p className="text-lg text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage internal team members and their roles. Manage internal team members and their roles.
</p> </p>
</div> </div>
<div className="flex gap-3 "> <div className="flex gap-3 ">
{hasPermission('users.create') && ( {hasPermission('users.create') && (
<Button <Button
onClick={() => setInviteDialogOpen(true)} onClick={() => setInviteDialogOpen(true)}
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-ink)] text-white rounded-xl h-12 px-6" className="btn-util-purple h-12 px-6"
> >
<Mail className="h-5 w-5 mr-2" /> <Mail className="h-5 w-5 mr-2" />
Invite Staff Invite Staff
@@ -154,7 +92,7 @@ const AdminStaff = () => {
{hasPermission('users.create') && ( {hasPermission('users.create') && (
<Button <Button
onClick={() => setCreateDialogOpen(true)} onClick={() => setCreateDialogOpen(true)}
className="bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white rounded-xl h-12 px-6" className="btn-util-green h-12 px-6"
> >
<UserPlus className="h-5 w-5 mr-2" /> <UserPlus className="h-5 w-5 mr-2" />
Create Staff Create Staff
@@ -165,42 +103,52 @@ const AdminStaff = () => {
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8"> <div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Staff</p> Quick Overview
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> </div>
{users.length} <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
</p> <StatCard
</Card> title="Total Staff"
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> //TODO: refractor codebase to have a central admin and user roles config - when user adds roles, they should be added to the config
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Admins</p> value={users.filter(u => ['admin', 'superadmin', 'finance', 'staff', 'media'].includes(u.role)).length}
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> icon={Users}
{users.filter(u => ['admin', 'superadmin'].includes(u.role)).length} iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
</p> dataTestId="stat-total-members"
</Card> />
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <StatCard
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Moderators</p> title="Admins"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={users.filter(u => ['admin', 'superadmin'].includes(u.role)).length}
{users.filter(u => u.role === 'moderator').length} icon={Shield}
</p> iconBgClass="text-[var(--green-light)]"
</Card> dataTestId="stat-active-members"
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> />
<p className="text-sm text-[var(--purple-lavender)] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p> <StatCard
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> title="Finance Managers"
{users.filter(u => u.status === 'active').length} value={users.filter(u => u.role === 'finance').length}
</p> icon={CreditCard}
</Card> iconBgClass="text-brand-light-orange"
dataTestId="stat-payment-pending-members"
/>
<StatCard
title="Inactive"
value={users.filter(u => ['admin', 'superadmin'].includes(u.role)).length && users.filter(u => u.status !== 'inactive').length}
icon={CircleMinus}
iconBgClass=" text-brand-pink"
dataTestId="stat-inactive-members"
/>
</div>
</div> </div>
{/* Tabs */} {/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-8"> <Tabs value={activeTab} onValueChange={setActiveTab} className="mb-8">
<TabsList className="grid w-full grid-cols-2 mb-8 "> <TabsList className="grid w-full grid-cols-2 mb-8 ">
<TabsTrigger value="staff-list" className="text-lg py-3"> <TabsTrigger value="staff-list" className="text-sm sm:text-md md:text-lg py-3">
<UserCog className="h-5 w-5 mr-2" /> <UserCog className="h-5 w-5 mr-2 hidden md:inline" />
Staff Members Staff Members
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="pending-invitations" className="text-lg py-3"> <TabsTrigger value="pending-invitations" className="text-sm sm:text-md md:text-lg py-3 ">
<Mail className="h-5 w-5 mr-2" /> <Mail className="h-5 w-5 mr-2 hidden md:inline" />
Pending Invitations Pending Invitations
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -210,12 +158,12 @@ const AdminStaff = () => {
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8"> <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[var(--purple-lavender)]" /> <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input <Input
placeholder="Search by name or email..." placeholder="Search by name or email..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
data-testid="search-staff-input" data-testid="search-staff-input"
/> />
</div> </div>
@@ -238,11 +186,13 @@ const AdminStaff = () => {
{/* Staff List */} {/* Staff List */}
{loading ? ( {loading ? (
<div className="text-center py-20"> <div className="text-center py-20">
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading staff...</p> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading staff...</p>
</div> </div>
) : filteredUsers.length > 0 ? ( ) : filteredUsers.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{filteredUsers.map((user) => ( {filteredUsers.map((user) => {
const joinedDate = user.member_since || user.created_at;
return (
<Card <Card
key={user.id} key={user.id}
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow" className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow"
@@ -261,13 +211,13 @@ const AdminStaff = () => {
<h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</h3> </h3>
{getRoleBadge(user.role)} <StatusBadge status={user.role} />
{getStatusBadge(user.status)} <StatusBadge status={user.status} />
</div> </div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p> <p>Email: {user.email}</p>
<p>Phone: {user.phone}</p> <p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p> <p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
{user.last_login && ( {user.last_login && (
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p> <p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
)} )}
@@ -280,7 +230,7 @@ const AdminStaff = () => {
<Button <Button
onClick={() => navigate(`/admin/users/${user.id}`)} onClick={() => navigate(`/admin/users/${user.id}`)}
variant="outline" variant="outline"
className="border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--lavender-300)] rounded-full px-4 py-2" 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" /> <Edit className="h-4 w-4 mr-2" />
Manage Manage
@@ -291,8 +241,8 @@ const AdminStaff = () => {
onClick={() => handleToggleStatus(user.id, user.status)} onClick={() => handleToggleStatus(user.id, user.status)}
variant="outline" variant="outline"
className={`border-2 rounded-full px-4 py-2 ${user.status === 'active' className={`border-2 rounded-full px-4 py-2 ${user.status === 'active'
? 'border-orange-500 text-orange-600 hover:bg-orange-50' ? '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' : 'border-green-500 text-green-600 hover:bg-green-50 hover:dark:bg-green-600/10'
}`} }`}
> >
{user.status === 'active' ? ( {user.status === 'active' ? (
@@ -313,7 +263,7 @@ const AdminStaff = () => {
<Button <Button
onClick={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)} onClick={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
variant="outline" variant="outline"
className="border-2 border-red-500 text-red-600 hover:bg-red-50 rounded-full px-4 py-2" 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" /> <Trash2 className="h-4 w-4 mr-2" />
Delete Delete
@@ -322,7 +272,8 @@ const AdminStaff = () => {
</div> </div>
</div> </div>
</Card> </Card>
))} );
})}
</div> </div>
) : ( ) : (
<div className="text-center py-20"> <div className="text-center py-20">
@@ -330,7 +281,7 @@ const AdminStaff = () => {
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Staff Found No Staff Found
</h3> </h3>
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery || roleFilter !== 'all' {searchQuery || roleFilter !== 'all'
? 'Try adjusting your filters' ? 'Try adjusting your filters'
: 'No staff members yet'} : 'No staff members yet'}
@@ -348,7 +299,7 @@ const AdminStaff = () => {
<CreateStaffDialog <CreateStaffDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
onSuccess={fetchStaff} onSuccess={refetch}
/> />
<InviteStaffDialog <InviteStaffDialog

View File

@@ -19,7 +19,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '../../components/ui/dialog'; } from '../../components/ui/dialog';
import { Badge } from '../../components/ui/badge';
import api from '../../utils/api'; import api from '../../utils/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -35,7 +34,15 @@ import {
Download, Download,
FileDown, FileDown,
AlertTriangle, AlertTriangle,
Info Info,
Repeat,
ChevronDown,
ChevronUp,
ExternalLink,
Copy
} from 'lucide-react'; } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@@ -43,6 +50,9 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
import StatusBadge from '@/components/StatusBadge';
import CreateSubscriptionDialog from '@/components/CreateSubscriptionDialog';
import SubscriptionsTable from '@/components/admin/SubscriptionsTable';
const AdminSubscriptions = () => { const AdminSubscriptions = () => {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -55,6 +65,10 @@ const AdminSubscriptions = () => {
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [planFilter, setPlanFilter] = useState('all'); const [planFilter, setPlanFilter] = useState('all');
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [expandedRows, setExpandedRows] = useState(new Set());
//create subsdcription dialog state
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Edit subscription dialog state // Edit subscription dialog state
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
@@ -265,41 +279,79 @@ Proceed with activation?`;
}); });
}; };
const getStatusBadgeVariant = (status) => { const formatDateTime = (dateString) => {
const variants = { if (!dateString) return 'N/A';
active: 'default', return new Date(dateString).toLocaleString('en-US', {
cancelled: 'destructive', year: 'numeric',
expired: 'secondary' month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}; };
return variants[status] || 'outline';
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) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-12 w-12 animate-spin text-[var(--purple-lavender)]" /> <Loader2 className="h-12 w-12 animate-spin text-brand-purple " />
</div> </div>
); );
} }
return ( return (
<>
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} {/* Header */}
<div className='flex justify-between'>
<div> <div>
<h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Management Subscription Management
</h1> </h1>
<p className="text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View and manage all member subscriptions View and manage all member subscriptions
</p> </p>
</div> </div>
{hasPermission('users.create') && (
<Button
onClick={() => setCreateDialogOpen(true)}
className="btn-util-green "
>
<Repeat className="h-5 w-5 mr-2" />
Create Subscription
</Button>
)}
</div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid md:grid-cols-4 gap-6"> <div className="grid md:grid-cols-4 gap-6">
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Subscriptions Total Subscriptions
</p> </p>
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -307,7 +359,7 @@ Proceed with activation?`;
</p> </p>
</div> </div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full"> <div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<CreditCard className="h-6 w-6 text-[var(--purple-lavender)]" /> <CreditCard className="h-6 w-6 text-brand-purple " />
</div> </div>
</div> </div>
</Card> </Card>
@@ -315,7 +367,7 @@ Proceed with activation?`;
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Active Members Active Members
</p> </p>
<p className="text-3xl font-bold text-[var(--green-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-bold text-[var(--green-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -331,7 +383,7 @@ Proceed with activation?`;
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Revenue Total Revenue
</p> </p>
<p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-bold text-[var(--purple-ink)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -339,7 +391,7 @@ Proceed with activation?`;
</p> </p>
</div> </div>
<div className="p-3 bg-[var(--neutral-800)]/20 rounded-full"> <div className="p-3 bg-[var(--neutral-800)]/20 rounded-full">
<DollarSign className="h-6 w-6 text-[var(--purple-lavender)]" /> <DollarSign className="h-6 w-6 text-brand-purple " />
</div> </div>
</div> </div>
</Card> </Card>
@@ -347,7 +399,7 @@ Proceed with activation?`;
<Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]"> <Card className="p-6 bg-background rounded-2xl border-2 border-[var(--neutral-800)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Donations Total Donations
</p> </p>
<p className="text-3xl font-bold text-[var(--orange-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-bold text-[var(--orange-light)] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
@@ -367,12 +419,12 @@ Proceed with activation?`;
{/* Search */} {/* Search */}
<div className="md:col-span-1"> <div className="md:col-span-1">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[var(--purple-lavender)]" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input <Input
placeholder="Search by name or email..." placeholder="Search by name or email..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
</div> </div>
@@ -409,7 +461,7 @@ Proceed with activation?`;
</div> </div>
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
<div className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
</div> </div>
@@ -419,7 +471,7 @@ Proceed with activation?`;
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
disabled={exporting} disabled={exporting}
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-soft)] rounded-full px-6 py-2 flex items-center gap-2" className="btn-green py-2 flex items-center gap-2"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
{exporting ? 'Exporting...' : 'Export'} {exporting ? 'Exporting...' : 'Export'}
@@ -430,14 +482,14 @@ Proceed with activation?`;
onClick={() => handleExport('all')} onClick={() => handleExport('all')}
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3" className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
> >
<FileDown className="h-4 w-4 mr-2 text-[var(--purple-lavender)]" /> <FileDown className="h-4 w-4 mr-2 text-brand-purple " />
<span className="text-[var(--purple-ink)]">Export All Subscriptions</span> <span className="text-[var(--purple-ink)]">Export All Subscriptions</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleExport('current')} onClick={() => handleExport('current')}
className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3" className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
> >
<FileDown className="h-4 w-4 mr-2 text-[var(--purple-lavender)]" /> <FileDown className="h-4 w-4 mr-2 text-brand-purple " />
<span className="text-[var(--purple-ink)]">Export Current View</span> <span className="text-[var(--purple-ink)]">Export Current View</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -460,22 +512,22 @@ Proceed with activation?`;
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.user.first_name} {sub.user.last_name} {sub.user.first_name} {sub.user.last_name}
</p> </p>
<p className="text-sm text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.user.email} {sub.user.email}
</p> </p>
</div> </div>
<Badge variant={getStatusBadgeVariant(sub.status)}>{sub.status}</Badge> <StatusBadge status={sub.status} />
</div> </div>
{/* Plan & Period */} {/* Plan & Period */}
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div> <div>
<p className="text-xs text-[var(--purple-lavender)] mb-1">Plan</p> <p className="text-xs text-brand-purple mb-1">Plan</p>
<p className="font-medium text-[var(--purple-ink)]">{sub.plan.name}</p> <p className="font-medium text-[var(--purple-ink)]">{sub.plan.name}</p>
<p className="text-xs text-[var(--purple-lavender)]">{sub.plan.billing_cycle}</p> <p className="text-xs text-brand-purple ">{sub.plan.billing_cycle}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--purple-lavender)] mb-1">Period</p> <p className="text-xs text-brand-purple mb-1">Period</p>
<p className="text-[var(--purple-ink)]"> <p className="text-[var(--purple-ink)]">
{new Date(sub.current_period_start).toLocaleDateString()} - {new Date(sub.current_period_start).toLocaleDateString()} -
{new Date(sub.current_period_end).toLocaleDateString()} {new Date(sub.current_period_end).toLocaleDateString()}
@@ -486,19 +538,19 @@ Proceed with activation?`;
{/* Pricing */} {/* Pricing */}
<div className="grid grid-cols-3 gap-2 text-sm bg-background/50 p-3 rounded"> <div className="grid grid-cols-3 gap-2 text-sm bg-background/50 p-3 rounded">
<div> <div>
<p className="text-xs text-[var(--purple-lavender)] mb-1">Base Fee</p> <p className="text-xs text-brand-purple mb-1">Base Fee</p>
<p className="font-medium text-[var(--purple-ink)]"> <p className="font-medium text-[var(--purple-ink)]">
${(sub.base_fee_cents / 100).toFixed(2)} ${(sub.base_fee_cents / 100).toFixed(2)}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--purple-lavender)] mb-1">Donation</p> <p className="text-xs text-brand-purple mb-1">Donation</p>
<p className="font-medium text-[var(--purple-ink)]"> <p className="font-medium text-[var(--purple-ink)]">
${(sub.donation_cents / 100).toFixed(2)} ${(sub.donation_cents / 100).toFixed(2)}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--purple-lavender)] mb-1">Total</p> <p className="text-xs text-brand-purple mb-1">Total</p>
<p className="font-semibold text-[var(--purple-ink)]"> <p className="font-semibold text-[var(--purple-ink)]">
${(sub.total_cents / 100).toFixed(2)} ${(sub.total_cents / 100).toFixed(2)}
</p> </p>
@@ -512,7 +564,7 @@ Proceed with activation?`;
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleEdit(sub)} onClick={() => handleEdit(sub)}
className="flex-1 text-[var(--purple-lavender)] hover:bg-[var(--neutral-800)]" className="flex-1 text-brand-purple hover:bg-[var(--neutral-800)]"
> >
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />
Edit Edit
@@ -521,9 +573,9 @@ Proceed with activation?`;
{sub.status === 'active' && hasPermission('subscriptions.cancel') && ( {sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline-destructive"
onClick={() => handleCancelSubscription(sub.id)} onClick={() => handleCancelSubscription(sub.id)}
className="flex-1 text-red-600 hover:bg-red-50" className="flex-1 "
> >
<XCircle className="h-4 w-4 mr-2" /> <XCircle className="h-4 w-4 mr-2" />
Cancel Cancel
@@ -534,7 +586,7 @@ Proceed with activation?`;
</Card> </Card>
)) ))
) : ( ) : (
<div className="p-12 text-center text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="p-12 text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No subscriptions found No subscriptions found
</div> </div>
)} )}
@@ -542,121 +594,29 @@ Proceed with activation?`;
{/* Desktop Table View */} {/* Desktop Table View */}
<div className="hidden md:block overflow-x-auto"> <div className="hidden md:block overflow-x-auto">
<table className="w-full"> <SubscriptionsTable
<thead> subscriptions={filteredSubscriptions}
<tr className="bg-[var(--neutral-800)]/20 border-b border-[var(--neutral-800)]"> expandedRows={expandedRows}
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}> onToggleRowExpansion={toggleRowExpansion}
Member onEdit={handleEdit}
</th> onCancel={handleCancelSubscription}
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}> hasPermission={hasPermission}
Plan formatDate={formatDate}
</th> formatDateTime={formatDateTime}
<th className="text-left p-4 text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}> formatPrice={formatPrice}
Status copyToClipboard={copyToClipboard}
</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-[var(--purple-lavender)]" 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-[var(--purple-lavender)]">
{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-[var(--purple-lavender)]">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-[var(--purple-lavender)] hover:bg-[var(--neutral-800)]"
>
<Edit className="h-4 w-4" />
</Button>
)}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button
size="sm"
variant="outline"
onClick={() => handleCancelSubscription(sub.id)}
className="text-red-600 hover:bg-red-50"
>
<XCircle className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="8" className="p-12 text-center text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No subscriptions found
</td>
</tr>
)}
</tbody>
</table>
</div> </div>
</Card> </Card>
{/* Edit Subscription Dialog */} {/* Edit Subscription Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}> <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px] bg-background rounded-2xl"> <DialogContent className="sm:max-w-[500px] bg-background rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Edit Subscription Edit Subscription
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update subscription status or end date for {selectedSubscription?.user.first_name} {selectedSubscription?.user.last_name} Update subscription status or end date for {selectedSubscription?.user.first_name} {selectedSubscription?.user.last_name}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -740,13 +700,13 @@ Proceed with activation?`;
End Date End Date
</Label> </Label>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[var(--purple-lavender)]" /> <Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input <Input
id="end_date" id="end_date"
type="date" type="date"
value={editFormData.end_date} value={editFormData.end_date}
onChange={(e) => setEditFormData({ ...editFormData, end_date: e.target.value })} onChange={(e) => setEditFormData({ ...editFormData, end_date: e.target.value })}
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)]" className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/> />
</div> </div>
</div> </div>
@@ -781,6 +741,13 @@ Proceed with activation?`;
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </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 { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '../../components/ui/avatar'; 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 { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import ChangeRoleDialog from '../../components/ChangeRoleDialog';
import StatusBadge from '../../components/StatusBadge';
import TransactionHistory from '../../components/TransactionHistory';
const AdminUserView = () => { const AdminUserView = () => {
const { userId } = useParams(); const { userId } = useParams();
@@ -16,21 +20,65 @@ const AdminUserView = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [resendVerificationLoading, setResendVerificationLoading] = useState(false); const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
const [subscriptions, setSubscriptions] = useState([]); const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true); const [transactionsLoading, setTransactionsLoading] = useState(true);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingAction, setPendingAction] = useState(null); const [pendingAction, setPendingAction] = useState(null);
const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
const [memberSince, setMemberSince] = useState('');
const [memberSinceSaving, setMemberSinceSaving] = useState(false);
const fileInputRef = useRef(null); 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(() => { useEffect(() => {
fetchConfig(); fetchConfig();
fetchUserProfile(); fetchUserProfile();
fetchSubscriptions(); fetchTransactions();
}, [userId]); }, [userId]);
useEffect(() => {
if (user) {
setMemberSince(formatDateInputValue(user.member_since));
}
}, [user]);
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
try { try {
const response = await api.get(`/admin/users/${userId}`); const response = await api.get(`/admin/users/${userId}`);
@@ -43,14 +91,15 @@ const AdminUserView = () => {
} }
}; };
const fetchSubscriptions = async () => { const fetchTransactions = async () => {
try { try {
const response = await api.get(`/admin/subscriptions?user_id=${userId}`); setTransactionsLoading(true);
setSubscriptions(response.data); const response = await api.get(`/admin/users/${userId}/transactions`);
setTransactions(response.data);
} catch (error) { } catch (error) {
console.error('Failed to fetch subscriptions:', error); console.error('Failed to fetch transactions:', error);
} finally { } 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 = () => { const getActionMessage = () => {
if (!pendingAction || !user) return {}; if (!pendingAction || !user) return {};
@@ -202,9 +272,18 @@ const AdminUserView = () => {
return {}; return {};
}; };
const handleRoleChanged = () => {
// Refresh user data after role change
fetchUserProfile();
};
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
if (!user) return null; if (!user) return null;
const joinedDate = user.created_at;
const memberSinceBaseline = formatDateInputValue(user.member_since);
const memberSinceHasChanges = memberSince !== memberSinceBaseline;
return ( return (
<> <>
{/* Back Button */} {/* Back Button */}
@@ -235,12 +314,13 @@ const AdminUserView = () => {
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</h1> </h1>
{/* Status & Role Badges */} {/* Status & Role Badges */}
<Badge>{user.status}</Badge> <StatusBadge status={user.status} />
<Badge>{user.role}</Badge> <StatusBadge status={user.role} />
</div> </div>
{/* Contact Info */} {/* Contact Info */}
<div className="grid md:grid-cols-2 gap-4 text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="grid md:grid-cols-2 gap-4 text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
<span>{user.email}</span> <span>{user.email}</span>
@@ -255,7 +335,7 @@ const AdminUserView = () => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span>Joined {new Date(user.created_at).toLocaleDateString()}</span> <span>Registered: {formatDateDisplayValue(joinedDate)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -272,12 +352,21 @@ const AdminUserView = () => {
onClick={handleResetPasswordRequest} onClick={handleResetPasswordRequest}
disabled={resetPasswordLoading} disabled={resetPasswordLoading}
variant="outline" variant="outline"
className="border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] hover:bg-[var(--lavender-300)] rounded-full px-4 py-2 disabled:opacity-50" className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-4 py-2 disabled:opacity-50"
> >
<Lock className="h-4 w-4 mr-2" /> <Lock className="h-4 w-4 mr-2" />
{resetPasswordLoading ? 'Resetting...' : 'Reset Password'} {resetPasswordLoading ? 'Resetting...' : 'Reset Password'}
</Button> </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 && ( {!user.email_verified && (
<Button <Button
onClick={handleResendVerificationRequest} onClick={handleResendVerificationRequest}
@@ -321,7 +410,7 @@ const AdminUserView = () => {
</Button> </Button>
)} )}
<div className="flex items-center gap-2 text-sm text-[var(--purple-lavender)] ml-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="flex items-center gap-2 text-sm text-brand-purple ml-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<span>User will receive a temporary password via email</span> <span>User will receive a temporary password via email</span>
</div> </div>
@@ -336,20 +425,41 @@ const AdminUserView = () => {
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div> <div>
<label className="text-sm font-medium text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</label> <label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</label>
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.address}</p> <p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.address}</p>
</div> </div>
<div> <div>
<label className="text-sm font-medium text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</label> <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" }}> <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> </p>
</div> </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 && ( {user.partner_first_name && (
<div> <div>
<label className="text-sm font-medium text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Partner</label> <label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Partner</label>
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.partner_first_name} {user.partner_last_name} {user.partner_first_name} {user.partner_last_name}
</p> </p>
@@ -358,14 +468,14 @@ const AdminUserView = () => {
{user.referred_by_member_name && ( {user.referred_by_member_name && (
<div> <div>
<label className="text-sm font-medium text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Referred By</label> <label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Referred By</label>
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.referred_by_member_name}</p> <p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.referred_by_member_name}</p>
</div> </div>
)} )}
{user.lead_sources && user.lead_sources.length > 0 && ( {user.lead_sources && user.lead_sources.length > 0 && (
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="text-sm font-medium text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Lead Sources</label> <label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Lead Sources</label>
<div className="flex flex-wrap gap-2 mt-2"> <div className="flex flex-wrap gap-2 mt-2">
{user.lead_sources.map((source, idx) => ( {user.lead_sources.map((source, idx) => (
<Badge key={idx} variant="outline">{source}</Badge> <Badge key={idx} variant="outline">{source}</Badge>
@@ -376,97 +486,17 @@ const AdminUserView = () => {
</div> </div>
</Card> </Card>
{/* Subscription Info (if applicable) */} {/* Transaction History */}
{user.role === 'member' && ( <div className="mt-8">
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] mt-8"> <TransactionHistory
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}> subscriptions={transactions.subscriptions}
Subscription Information donations={transactions.donations}
</h2> totalSubscriptionCents={transactions.total_subscription_amount_cents}
totalDonationCents={transactions.total_donation_amount_cents}
{subscriptionsLoading ? ( loading={transactionsLoading}
<p className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading subscriptions...</p> isAdmin={true}
) : subscriptions.length === 0 ? ( />
<p className="text-[var(--purple-lavender)]" 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-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.plan.billing_cycle}
</p>
</div> </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-[var(--purple-lavender)] 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-[var(--purple-lavender)] 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-[var(--purple-lavender)] 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-[var(--purple-lavender)] 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-[var(--purple-lavender)] 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-[var(--purple-lavender)] 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-[var(--purple-lavender)] 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>
)}
{/* Admin Action Confirmation Dialog */} {/* Admin Action Confirmation Dialog */}
<ConfirmationDialog <ConfirmationDialog
@@ -476,6 +506,14 @@ const AdminUserView = () => {
loading={resetPasswordLoading || resendVerificationLoading} loading={resetPasswordLoading || resendVerificationLoading}
{...getActionMessage()} {...getActionMessage()}
/> />
{/* Change Role Dialog */}
<ChangeRoleDialog
open={changeRoleDialogOpen}
onClose={() => setChangeRoleDialogOpen(false)}
user={user}
onSuccess={handleRoleChanged}
/>
</> </>
); );
}; };

Some files were not shown because too many files have changed in this diff Show More