26 Commits

Author SHA1 Message Date
691dbad1b4 Merge pull request 'Merge from dev to loaf-prod for DEMO' (#25) from dev into loaf-prod
Reviewed-on: #25
2026-02-02 11:12:58 +00:00
68fc34d0a5 Update Stripe publishable key storage in Stripe Settings
1. Created src/hooks/use-stripe-config.js - New hook that:
	- Fetches publishable key from /api/config/stripe
	- Returns a pre-initialized stripePromise for use with Stripe Elements
	- Caches the result to avoid multiple API calls
	- Falls back to REACT_APP_STRIPE_PUBLISHABLE_KEY env var if API fails
2. Updated AdminSettings.js - Added publishable key input field in the Stripe settings form
3. Updated AdminPaymentMethodsPanel.js - Uses useStripeConfig hook instead of env variable
4. Updated PaymentMethodsSection.js - Uses useStripeConfig hook instead of env variable
2026-02-02 17:55:00 +07:00
82ef36b439 Conditional Rules in Registration Builder Fix
1. Trigger Field Selection - Dropdown to select which field triggers the rule (filters to checkbox, dropdown, radio, text fields)
2. Operator Selection - Dropdown with options:
	- equals
	- not equals
	- contains
	- is not empty
	- is empty
3. Value Input - Smart input based on field type:
	- Checkbox fields → dropdown with "Checked (true)" / "Unchecked (false)"
	- empty/not_empty operators → disabled (no value needed)
	- Other fields → text input
4. Action Selection - Dropdown with options:
	- Show fields
	- Hide fields
	- Make required
	- Make optional
5. Target Fields - Checkbox list of all fields (excluding the trigger field) to select which fields are affected
6. Rule Summary - A blue info box at the bottom of each rule showing a human-readable summary of the configured rule
2026-02-02 17:29:03 +07:00
b3e6cfba84 no message 2026-02-02 17:08:50 +07:00
d4acef8d90 - Created useDirectoryConfig hook (src/hooks/use-directory-config.js)
- Updated Profile.js - conditional rendering with isFieldEnabled()
- Updated MemberCard.js - conditional rendering for directory fields
- Updated MembersDirectory.js - conditional rendering in profile dialog
- Created AdminDirectorySettings.js - Admin UI for toggling fields
- Updated SettingsSidebar.js - Added Directory and Registration tabs
- Updated App.js - Added routes for new settings pages
2026-02-02 17:08:11 +07:00
kayela
21338f1541 feat: restruction of admin sidebar, button slightly adjusted, member tiers header added, routing for sidbar adjusted 2026-02-01 16:44:55 -06:00
kayela
da366272b4 fix: fixed total pending display 2026-02-01 15:36:43 -06:00
kayela
af27190e29 Phone formatting works, start card moved, registration styling changed 2026-02-01 15:16:12 -06:00
kayela
235156a9ee Merge branch 'features' into dev 2026-02-01 10:44:12 -06:00
Koncept Kit
68ee22c124 Changes 2026-02-01 19:53:45 +07:00
Koncept Kit
5d085153f6 1. New Components- src/components/PaymentMethodCard.js - Displays individual payment method- src/components/AddPaymentMethodDialog.js - Stripe Elements dialog for adding cards- src/components/PaymentMethodsSection.js - Main payment methods UI- src/components/PasswordConfirmDialog.js - Admin password re-entry dialog- src/components/admin/AdminPaymentMethodsPanel.js - Admin panel for user payment methods2. Profile Integration (src/pages/Profile.js)- Replaced placeholder Payment Method section with PaymentMethodsSection3. Admin Integration (src/pages/admin/AdminUserView.js)- Added AdminPaymentMethodsPanel to user detail view 2026-01-31 01:09:37 +07:00
kayela
01a3c38085 fix: button text now visable 2026-01-30 09:50:33 -06:00
kayela
7152382dca fix: member directory link works and stat card changes 2026-01-30 09:38:42 -06:00
kayela
529d3d4697 Merge branch 'theme-provider' into dev 2026-01-29 21:50:28 -06:00
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
4423576fa2 Merge branch 'theme-provider' into dev 2026-01-29 00:01:43 -06:00
a1c68eedc2 Merge pull request 'theme-provider' (#22) from theme-provider into dev
Reviewed-on: #22
2026-01-28 01:50:41 +00:00
48c5a916d9 Merge pull request 'dev' (#19) from dev into loaf-prod
Reviewed-on: #19
2026-01-26 11:22:19 +00:00
1f9e6ea191 Merge pull request 'Remove View Public Site on AdminSidebar' (#14) from dev into loaf-prod
Reviewed-on: #14
2026-01-08 17:24:35 +00:00
66c2bedbed Merge pull request 'Merge from Dev to LOAF Production' (#13) from dev into loaf-prod
Reviewed-on: #13
2026-01-07 08:44:10 +00:00
d94ea7b6d5 Merge pull request 'feat(frontend): Comprehensive RBAC implementation across admin pages' (#10) from dev into loaf-prod
Reviewed-on: #10
2026-01-06 08:35:56 +00:00
24519a7080 Merge pull request 'Improve UX with navigation, attendance management, and calendar fixes' (#9) from dev into loaf-prod
Reviewed-on: #9
2026-01-05 18:08:57 +00:00
b1b9a05d4f Merge pull request 'Merge from Dev' (#8) from dev into loaf-prod
Reviewed-on: #8
2026-01-05 08:49:42 +00:00
a2070b4e4e Merge pull request 'Fix staff invitation acceptance & add delete/deactivate buttons' (#7) from dev into loaf-prod
Reviewed-on: #7
2026-01-04 17:12:03 +00:00
6a21d32319 Merge pull request 'LOAF Prod' (#6) from dev into loaf-prod
Reviewed-on: #6
2026-01-04 12:48:26 +00:00
28 changed files with 5621 additions and 786 deletions

View File

@@ -47,6 +47,8 @@ import AdminGallery from './pages/admin/AdminGallery';
import AdminNewsletters from './pages/admin/AdminNewsletters';
import AdminFinancials from './pages/admin/AdminFinancials';
import AdminBylaws from './pages/admin/AdminBylaws';
import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder';
import AdminDirectorySettings from './pages/admin/AdminDirectorySettings';
import History from './pages/History';
import MissionValues from './pages/MissionValues';
import BoardOfDirectors from './pages/BoardOfDirectors';
@@ -238,6 +240,20 @@ function App() {
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/registration" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminRegistrationBuilder />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/member-tiers" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminMemberTiers />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/plans" element={
<PrivateRoute adminOnly>
<AdminLayout>
@@ -292,6 +308,7 @@ function App() {
<Navigate to="/admin/settings/permissions" replace />
</PrivateRoute>
} />
<Route path="/admin/settings" element={
<PrivateRoute adminOnly>
<AdminLayout>
@@ -302,8 +319,9 @@ function App() {
<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 path="directory" element={<AdminDirectorySettings />} />
<Route path="registration" element={<AdminRegistrationBuilder />} />
</Route>
{/* 404 - Catch all undefined routes */}

View File

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

View File

@@ -27,6 +27,8 @@ import {
Heart,
Sun,
Moon,
Star,
FileEdit
} from 'lucide-react';
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
@@ -104,18 +106,31 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
path: '/admin',
disabled: false
},
{
name: 'Staff',
name: 'Staff & Admins',
icon: UserCog,
path: '/admin/staff',
disabled: false
},
{
name: 'Members',
name: 'Member Roster',
icon: Users,
path: '/admin/members',
disabled: false
},
{
name: 'Member Tiers',
icon: Star,
path: '/admin/member-tiers',
disabled: false
},
{
name: 'Registration',
icon: FileEdit,
path: '/admin/registration',
disabled: false
},
{
name: 'Validations',
icon: CheckCircle,
@@ -316,6 +331,18 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* Dashboard - Standalone */}
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
{/* Onboarding Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Onboarding
</h3>
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Registration'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
</div>
{/* MEMBERSHIP Section */}
{isOpen && (
<div className="px-4 py-2 mt-6">
@@ -325,9 +352,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div>
)}
<div className="space-y-1">
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Members'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Roster'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Tiers'))}
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff & Admins'))}
</div>
{/* FINANCIALS Section */}

View File

@@ -128,7 +128,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
<Button
type="button"
onClick={() => onOpenChange(false)}
className="btn-outline mr-33"
className="btn-outline mr-33 text-white"
>
Cancel
</Button>

View File

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

View File

@@ -3,6 +3,7 @@ import { Card } from './ui/card';
import { Button } from './ui/button';
import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
import MemberBadge from './MemberBadge';
import useDirectoryConfig from '../hooks/use-directory-config';
// Helper function to get initials
const getInitials = (firstName, lastName) => {
@@ -20,6 +21,7 @@ const getSocialMediaLink = (url) => {
const MemberCard = ({ member, onViewProfile, tiers }) => {
const memberSince = member.member_since || member.created_at;
const { isFieldEnabled } = useDirectoryConfig();
return (
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
{/* Member Tier Badge */}
@@ -48,7 +50,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
</h3>
{/* Partner Name */}
{member.directory_partner_name && (
{isFieldEnabled('directory_partner_name') && member.directory_partner_name && (
<div className="flex items-center justify-center gap-2 mb-4">
<Heart className="h-4 w-4 text-[var(--orange-light)]" />
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -58,7 +60,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
)}
{/* Bio */}
{member.directory_bio && (
{isFieldEnabled('directory_bio') && member.directory_bio && (
<p className="text-brand-purple text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{member.directory_bio}
</p>
@@ -79,7 +81,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
{/* Contact Information */}
<div className="space-y-3 mb-4">
{member.directory_email && (
{isFieldEnabled('directory_email') && member.directory_email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-brand-purple flex-shrink-0" />
<a
@@ -92,7 +94,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
</div>
)}
{member.directory_phone && (
{isFieldEnabled('directory_phone') && member.directory_phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-brand-purple flex-shrink-0" />
<a
@@ -105,7 +107,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
</div>
)}
{member.directory_address && (
{isFieldEnabled('directory_address') && member.directory_address && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="h-4 w-4 text-brand-purple flex-shrink-0 mt-0.5" />
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -116,7 +118,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
</div>
{/* Social Media Links */}
{(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
{isFieldEnabled('social_media') && (member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
<div className="pt-4 border-t border-[var(--neutral-800)]">
<div className="flex justify-center gap-3">
{member.social_media_facebook && (

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { CreditCard, Shield, Star, Palette } from 'lucide-react';
import { CreditCard, Shield, Star, Palette, FileEdit, BookUser } from 'lucide-react';
const settingsItems = [
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
{ label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star },
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
{ label: 'Directory', path: '/admin/settings/directory', icon: BookUser },
];
const SettingsTabs = () => {

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
@@ -9,12 +9,77 @@ import {
} 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', {
@@ -44,6 +109,91 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
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">
@@ -109,10 +259,214 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
Registration Details
</h3>
<InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} />
<InfoRow icon={UserCheck} label="Referred By" value={user.referred_by_member_name} />
<InfoRow icon={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">
@@ -156,6 +510,19 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
</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)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -71,7 +71,7 @@ const AdminDashboard = () => {
</div>
<Link to={'/'} className=''>
<Button
className="btn-lavender mb-8 md:mb-0 "
className="btn-lavender mb-8 md:mb-0 mr-4 "
>
<Globe />
View Public Site

View File

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

View File

@@ -150,9 +150,15 @@ const AdminMemberTiers = () => {
<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>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Tiers
</h1>
<p className="text-muted-foreground">
Configure tier names, time ranges, and badges displayed in the members directory.
</p>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<Button variant="outline" onClick={handleDiscardChanges}>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -12,6 +12,7 @@ import ConfirmationDialog from '../../components/ConfirmationDialog';
import ChangeRoleDialog from '../../components/ChangeRoleDialog';
import StatusBadge from '../../components/StatusBadge';
import TransactionHistory from '../../components/TransactionHistory';
import AdminPaymentMethodsPanel from '../../components/admin/AdminPaymentMethodsPanel';
const AdminUserView = () => {
const { userId } = useParams();
@@ -417,6 +418,14 @@ const AdminUserView = () => {
</div>
</Card>
{/* Payment Methods Panel */}
<div className="mb-8">
<AdminPaymentMethodsPanel
userId={userId}
userName={`${user.first_name} ${user.last_name}`}
/>
</div>
{/* Additional Details */}
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>

View File

@@ -34,7 +34,20 @@ import {
PaginationEllipsis,
} from '../../components/ui/pagination';
import { toast } from 'sonner';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X, FileText, XCircle } from 'lucide-react';
import {
CheckCircle,
Clock,
Search,
ArrowUp,
ArrowDown,
X,
FileText,
XCircle,
Users,
Mail,
ShieldCheck,
CreditCard
} from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import RejectionDialog from '../../components/RejectionDialog';
@@ -341,35 +354,29 @@ const AdminValidations = () => {
Quick Overview
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<StatCard
title="Total Pending"
value={loading ? '-' : pendingUsers.length}
icon={CheckCircle}
iconBgClass="text-brand-purple"
dataTestId="stat-total-users"
/>
<StatCard
title="Awaiting Email"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
icon={CheckCircle}
iconBgClass="text-brand-purple"
icon={Mail}
iconBgClass="text-brand-pink"
dataTestId="stat-total-users"
/>
<StatCard
title="Pending Validation"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
icon={CheckCircle}
iconBgClass="text-brand-purple"
icon={ShieldCheck}
iconBgClass="text-success"
dataTestId="stat-pending-validation"
/>
<StatCard
title="Payment Pending"
value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
icon={CheckCircle}
iconBgClass="text-brand-purple"
icon={CreditCard}
iconBgClass="text-accent"
dataTestId="stat-payment-pending"
/>
@@ -381,7 +388,13 @@ const AdminValidations = () => {
dataTestId="stat-rejected"
/>
<StatCard
title="Total Pending"
value={loading ? '-' : pendingUsers.filter(user => ['pending_email', 'pending_validation', 'pre_validated', 'payment_pending',].includes(user.status)).length}
icon={Users}
iconBgClass="text-brand-purple"
dataTestId="stat-total-users"
/>
</div>
</Card>
@@ -463,7 +476,7 @@ const AdminValidations = () => {
{user.first_name} {user.last_name}
</div>
<div className='text-brand-purple'>
{user.email}
{user.email}
</div>
</TableCell>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
import { Link } from 'react-router-dom';
import { Card } from '../../components/ui/card';
import { Input } from '../../components/ui/input';
import { Badge } from '../../components/ui/badge';
@@ -19,6 +20,7 @@ import MemberCard from '../../components/MemberCard';
import MemberBadge from '../../components/MemberBadge';
import useMembers from '../../hooks/use-members';
import useMemberTiers from '../../hooks/use-member-tiers';
import useDirectoryConfig from '../../hooks/use-directory-config';
const MembersDirectory = () => {
const [selectedMember, setSelectedMember] = useState(null);
@@ -27,6 +29,7 @@ const MembersDirectory = () => {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12;
const { tiers } = useMemberTiers();
const { isFieldEnabled } = useDirectoryConfig();
const allowedRoles = useMemo(() => [], []);
const normalizeStatus = useCallback((status) => {
if (typeof status === 'string') {
@@ -135,7 +138,7 @@ const MembersDirectory = () => {
}
return (
<div className="min-h-screen bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)]">
<div className="min-h-screen bg-gradient-to-bl from-white to-muted">
<Navbar />
<div className="max-w-7xl mx-auto py-12">
@@ -154,7 +157,7 @@ const MembersDirectory = () => {
</div>
{/* Search Bar */}
<div className="mb-24 mx-10">
<div className="mb-24 w-full">
<div className="relative w-full ">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
<Input
@@ -221,9 +224,10 @@ const MembersDirectory = () => {
</h3>
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '}
<a href="/members/profile" className="text-[var(--orange-light)] hover:underline font-medium">
<Link to="/profile" className="text-[var(--orange-light)] hover:underline font-medium">
Edit your profile
</a>
</Link>
</p>
</div>
</div>
@@ -240,7 +244,7 @@ const MembersDirectory = () => {
{selectedMember.first_name} {selectedMember.last_name}
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
</DialogTitle>
{selectedMember.directory_partner_name && (
{isFieldEnabled('directory_partner_name') && selectedMember.directory_partner_name && (
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
<span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span>
@@ -250,7 +254,7 @@ const MembersDirectory = () => {
<div className="space-y-6 py-4">
{/* Bio */}
{selectedMember.directory_bio && (
{isFieldEnabled('directory_bio') && selectedMember.directory_bio && (
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
About
@@ -262,79 +266,81 @@ const MembersDirectory = () => {
)}
{/* Contact Information */}
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Contact Information
</h3>
<div className="space-y-3">
{selectedMember.directory_email && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Mail className="h-5 w-5 text-brand-purple " />
{(isFieldEnabled('directory_email') || isFieldEnabled('directory_phone') || isFieldEnabled('directory_address') || isFieldEnabled('directory_dob')) && (
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Contact Information
</h3>
<div className="space-y-3">
{isFieldEnabled('directory_email') && selectedMember.directory_email && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Mail className="h-5 w-5 text-brand-purple " />
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<a
href={`mailto:${selectedMember.directory_email}`}
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{selectedMember.directory_email}
</a>
</div>
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<a
href={`mailto:${selectedMember.directory_email}`}
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{selectedMember.directory_email}
</a>
</div>
</div>
)}
)}
{selectedMember.directory_phone && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Phone className="h-5 w-5 text-brand-purple " />
{isFieldEnabled('directory_phone') && selectedMember.directory_phone && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Phone className="h-5 w-5 text-brand-purple " />
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Phone</p>
<a
href={`tel:${selectedMember.directory_phone}`}
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{selectedMember.directory_phone}
</a>
</div>
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Phone</p>
<a
href={`tel:${selectedMember.directory_phone}`}
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{selectedMember.directory_phone}
</a>
</div>
</div>
)}
)}
{selectedMember.directory_address && (
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<MapPin className="h-5 w-5 text-brand-purple " />
{isFieldEnabled('directory_address') && selectedMember.directory_address && (
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<MapPin className="h-5 w-5 text-brand-purple " />
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedMember.directory_address}
</p>
</div>
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedMember.directory_address}
</p>
</div>
</div>
)}
)}
{selectedMember.directory_dob && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
{isFieldEnabled('directory_dob') && selectedMember.directory_dob && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Birthday</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(selectedMember.directory_dob)}
</p>
</div>
</div>
<div>
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Birthday</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(selectedMember.directory_dob)}
</p>
</div>
</div>
)}
)}
</div>
</div>
</div>
)}
{/* Volunteer Interests */}
{selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
{isFieldEnabled('volunteer_interests') && selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests
@@ -353,7 +359,7 @@ const MembersDirectory = () => {
)}
{/* Social Media */}
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
{isFieldEnabled('social_media') && (selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>

View File

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