16 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
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
20 changed files with 1321 additions and 444 deletions

View File

@@ -48,6 +48,7 @@ import AdminNewsletters from './pages/admin/AdminNewsletters';
import AdminFinancials from './pages/admin/AdminFinancials'; import AdminFinancials from './pages/admin/AdminFinancials';
import AdminBylaws from './pages/admin/AdminBylaws'; import AdminBylaws from './pages/admin/AdminBylaws';
import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder'; import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder';
import AdminDirectorySettings from './pages/admin/AdminDirectorySettings';
import History from './pages/History'; import History from './pages/History';
import MissionValues from './pages/MissionValues'; import MissionValues from './pages/MissionValues';
import BoardOfDirectors from './pages/BoardOfDirectors'; import BoardOfDirectors from './pages/BoardOfDirectors';
@@ -239,6 +240,20 @@ function App() {
</AdminLayout> </AdminLayout>
</PrivateRoute> </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={ <Route path="/admin/plans" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <AdminLayout>
@@ -293,6 +308,7 @@ function App() {
<Navigate to="/admin/settings/permissions" replace /> <Navigate to="/admin/settings/permissions" replace />
</PrivateRoute> </PrivateRoute>
} /> } />
<Route path="/admin/settings" element={ <Route path="/admin/settings" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <AdminLayout>
@@ -303,8 +319,8 @@ function App() {
<Route index element={<Navigate to="stripe" replace />} /> <Route index element={<Navigate to="stripe" replace />} />
<Route path="stripe" element={<AdminSettings />} /> <Route path="stripe" element={<AdminSettings />} />
<Route path="permissions" element={<AdminRoles />} /> <Route path="permissions" element={<AdminRoles />} />
<Route path="member-tiers" element={<AdminMemberTiers />} />
<Route path="theme" element={<AdminTheme />} /> <Route path="theme" element={<AdminTheme />} />
<Route path="directory" element={<AdminDirectorySettings />} />
<Route path="registration" element={<AdminRegistrationBuilder />} /> <Route path="registration" element={<AdminRegistrationBuilder />} />
</Route> </Route>

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { import {
Dialog, 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) * - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
* - Auto-logout on expiration * - Auto-logout on expiration
* - "Stay Logged In" extends session * - "Stay Logged In" extends session
* - Checks session validity when tab becomes visible after being hidden
*/ */
const IdleSessionWarning = () => { const IdleSessionWarning = () => {
const { user, logout, refreshUser } = useAuth(); const { user, logout, refreshUser } = useAuth();
@@ -33,11 +35,13 @@ const IdleSessionWarning = () => {
const [showWarning, setShowWarning] = useState(false); const [showWarning, setShowWarning] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(60); // seconds const [timeRemaining, setTimeRemaining] = useState(60); // seconds
const [isExtending, setIsExtending] = useState(false); const [isExtending, setIsExtending] = useState(false);
const [isCheckingSession, setIsCheckingSession] = useState(false);
const activityTimeoutRef = useRef(null); const activityTimeoutRef = useRef(null);
const warningTimeoutRef = useRef(null); const warningTimeoutRef = useRef(null);
const countdownIntervalRef = useRef(null); const countdownIntervalRef = useRef(null);
const lastActivityRef = useRef(Date.now()); const lastActivityRef = useRef(Date.now());
const lastVisibilityCheckRef = useRef(Date.now());
// Reset activity timer // Reset activity timer
const resetActivityTimer = useCallback(() => { 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 // Track user activity
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;

View File

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

View File

@@ -1,18 +1,15 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js'; import { Elements } from '@stripe/react-stripe-js';
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react'; import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api from '../utils/api'; import api from '../utils/api';
import useStripeConfig from '../hooks/use-stripe-config';
import PaymentMethodCard from './PaymentMethodCard'; import PaymentMethodCard from './PaymentMethodCard';
import AddPaymentMethodDialog from './AddPaymentMethodDialog'; import AddPaymentMethodDialog from './AddPaymentMethodDialog';
import ConfirmationDialog from './ConfirmationDialog'; import ConfirmationDialog from './ConfirmationDialog';
// Initialize Stripe with publishable key from environment
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);
/** /**
* PaymentMethodsSection - Manages user payment methods * PaymentMethodsSection - Manages user payment methods
* *
@@ -28,6 +25,9 @@ const PaymentMethodsSection = () => {
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Get Stripe configuration from API
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
// Dialog states // Dialog states
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
const [clientSecret, setClientSecret] = useState(null); const [clientSecret, setClientSecret] = useState(null);

View File

@@ -1,13 +1,12 @@
import React from 'react'; import React from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { CreditCard, Shield, Star, Palette, FileEdit } from 'lucide-react'; import { CreditCard, Shield, Star, Palette, FileEdit, BookUser } from 'lucide-react';
const settingsItems = [ const settingsItems = [
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard }, { label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield }, { 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: 'Theme', path: '/admin/settings/theme', icon: Palette },
{ label: 'Registration Form', path: '/admin/settings/registration', icon: FileEdit }, { label: 'Directory', path: '/admin/settings/directory', icon: BookUser },
]; ];
const SettingsTabs = () => { const SettingsTabs = () => {

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js'; import { Elements } from '@stripe/react-stripe-js';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
@@ -26,13 +25,11 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api from '../../utils/api'; import api from '../../utils/api';
import useStripeConfig from '../../hooks/use-stripe-config';
import ConfirmationDialog from '../ConfirmationDialog'; import ConfirmationDialog from '../ConfirmationDialog';
import PasswordConfirmDialog from '../PasswordConfirmDialog'; import PasswordConfirmDialog from '../PasswordConfirmDialog';
import AddPaymentMethodDialog from '../AddPaymentMethodDialog'; import AddPaymentMethodDialog from '../AddPaymentMethodDialog';
// Initialize Stripe with publishable key from environment
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);
/** /**
* Get icon for payment method type * Get icon for payment method type
*/ */
@@ -76,6 +73,9 @@ const AdminPaymentMethodsPanel = ({ userId, userName }) => {
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Get Stripe configuration from API
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
// Dialog states // Dialog states
const [addCardDialogOpen, setAddCardDialogOpen] = useState(false); const [addCardDialogOpen, setAddCardDialogOpen] = useState(false);
const [addManualDialogOpen, setAddManualDialogOpen] = useState(false); const [addManualDialogOpen, setAddManualDialogOpen] = useState(false);

View File

@@ -47,6 +47,15 @@ const DynamicFormField = ({
const hasError = errors.length > 0; const hasError = errors.length > 0;
const errorMessage = errors[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 // Common input className
const inputClassName = `h-14 rounded-xl border-2 ${ const inputClassName = `h-14 rounded-xl border-2 ${
hasError hasError
@@ -59,6 +68,11 @@ const DynamicFormField = ({
const { value: newValue, type: inputType, checked } = e.target; const { value: newValue, type: inputType, checked } = e.target;
if (inputType === 'checkbox') { if (inputType === 'checkbox') {
onChange(id, checked); onChange(id, checked);
return;
}
if (type === 'phone') {
onChange(id, formatPhoneNumber(newValue));
return;
} else { } else {
onChange(id, newValue); onChange(id, newValue);
} }
@@ -111,6 +125,8 @@ const DynamicFormField = ({
value={value || ''} value={value || ''}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
inputMode={type === 'phone' ? 'numeric' : undefined}
maxLength={type === 'phone' ? 14 : undefined}
className={inputClassName} className={inputClassName}
data-testid={`field-${id}`} data-testid={`field-${id}`}
/> />

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 React, { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link, useLocation, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
@@ -13,6 +13,8 @@ import { ArrowRight, ArrowLeft } from 'lucide-react';
const Login = () => { const Login = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { login } = useAuth(); const { login } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -20,6 +22,30 @@ const Login = () => {
password: '' 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 handleInputChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value })); setFormData(prev => ({ ...prev, [name]: value }));

View File

@@ -13,6 +13,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
import ChangePasswordDialog from '../components/ChangePasswordDialog'; import ChangePasswordDialog from '../components/ChangePasswordDialog';
import PaymentMethodsSection from '../components/PaymentMethodsSection'; import PaymentMethodsSection from '../components/PaymentMethodsSection';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import useDirectoryConfig from '../hooks/use-directory-config';
const Profile = () => { const Profile = () => {
const { user } = useAuth(); const { user } = useAuth();
@@ -29,6 +30,7 @@ const Profile = () => {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [initialFormData, setInitialFormData] = useState(null); const [initialFormData, setInitialFormData] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { isFieldEnabled, loading: directoryConfigLoading } = useDirectoryConfig();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
first_name: '', first_name: '',
last_name: '', last_name: '',
@@ -427,107 +429,121 @@ const Profile = () => {
</div> </div>
{/* Member Directory Settings */} {/* Member Directory Settings */}
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4"> {isFieldEnabled('show_in_directory') && (
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<BookUser className="h-5 w-5 text-[var(--orange-light)]" /> <h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Member Directory Settings <BookUser className="h-5 w-5 text-[var(--orange-light)]" />
</h4> Member Directory Settings
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> </h4>
Control your visibility and information in the member directory. <p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
</p> Control your visibility and information in the member directory.
</p>
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg"> <div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg">
<input <input
type="checkbox" type="checkbox"
id="show_in_directory" id="show_in_directory"
name="show_in_directory" name="show_in_directory"
checked={formData.show_in_directory} checked={formData.show_in_directory}
onChange={handleCheckboxChange} onChange={handleCheckboxChange}
className="ui-checkbox" className="ui-checkbox"
/> />
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium"> <Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium">
Include me in the member directory Include me in the member directory
</Label> </Label>
</div>
{formData.show_in_directory && (
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
<div>
<Label htmlFor="directory_email">Directory Email</Label>
<Input
id="directory_email"
name="directory_email"
type="email"
value={formData.directory_email}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - email to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_bio">Bio</Label>
<Textarea
id="directory_bio"
name="directory_bio"
value={formData.directory_bio}
onChange={handleInputChange}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
placeholder="Tell other members about yourself..."
/>
</div>
<div>
<Label htmlFor="directory_address">Address</Label>
<Input
id="directory_address"
name="directory_address"
value={formData.directory_address}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - address to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_phone">Phone</Label>
<Input
id="directory_phone"
name="directory_phone"
type="tel"
value={formData.directory_phone}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - phone to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_dob">Date of Birth</Label>
<Input
id="directory_dob"
name="directory_dob"
type="date"
value={formData.directory_dob}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
<div>
<Label htmlFor="directory_partner_name">Partner Name</Label>
<Input
id="directory_partner_name"
name="directory_partner_name"
value={formData.directory_partner_name}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - partner name to show in directory"
/>
</div>
</div> </div>
)}
</div> {formData.show_in_directory && (
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
{isFieldEnabled('directory_email') && (
<div>
<Label htmlFor="directory_email">Directory Email</Label>
<Input
id="directory_email"
name="directory_email"
type="email"
value={formData.directory_email}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - email to show in directory"
/>
</div>
)}
{isFieldEnabled('directory_bio') && (
<div>
<Label htmlFor="directory_bio">Bio</Label>
<Textarea
id="directory_bio"
name="directory_bio"
value={formData.directory_bio}
onChange={handleInputChange}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
placeholder="Tell other members about yourself..."
/>
</div>
)}
{isFieldEnabled('directory_address') && (
<div>
<Label htmlFor="directory_address">Address</Label>
<Input
id="directory_address"
name="directory_address"
value={formData.directory_address}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - address to show in directory"
/>
</div>
)}
{isFieldEnabled('directory_phone') && (
<div>
<Label htmlFor="directory_phone">Phone</Label>
<Input
id="directory_phone"
name="directory_phone"
type="tel"
value={formData.directory_phone}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - phone to show in directory"
/>
</div>
)}
{isFieldEnabled('directory_dob') && (
<div>
<Label htmlFor="directory_dob">Date of Birth</Label>
<Input
id="directory_dob"
name="directory_dob"
type="date"
value={formData.directory_dob}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
)}
{isFieldEnabled('directory_partner_name') && (
<div>
<Label htmlFor="directory_partner_name">Partner Name</Label>
<Input
id="directory_partner_name"
name="directory_partner_name"
value={formData.directory_partner_name}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - partner name to show in directory"
/>
</div>
)}
</div>
)}
</div>
)}
</Card> </Card>
); );
@@ -664,34 +680,36 @@ const Profile = () => {
</div> </div>
{/* Volunteer Interests */} {/* Volunteer Interests */}
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4"> {isFieldEnabled('volunteer_interests') && (
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<Users className="h-5 w-5 text-brand-purple" /> <h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests <Users className="h-5 w-5 text-brand-purple" />
</h4> Volunteer Interests
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> </h4>
Select areas where you'd like to volunteer and help our community. <p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
</p> Select areas where you'd like to volunteer and help our community.
<div className="grid md:grid-cols-2 gap-3"> </p>
{volunteerOptions.map(option => ( <div className="grid md:grid-cols-2 gap-3">
<div key={option} className="flex items-center gap-3"> {volunteerOptions.map(option => (
<input <div key={option} className="flex items-center gap-3">
type="checkbox" <input
id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`} type="checkbox"
checked={formData.volunteer_interests.includes(option)} id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
onChange={() => handleVolunteerToggle(option)} checked={formData.volunteer_interests.includes(option)}
className="ui-checkbox" onChange={() => handleVolunteerToggle(option)}
/> className="ui-checkbox"
<Label />
htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`} <Label
className="cursor-pointer text-[var(--purple-ink)]" htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
> className="cursor-pointer text-[var(--purple-ink)]"
{option} >
</Label> {option}
</div> </Label>
))} </div>
))}
</div>
</div> </div>
</div> )}
</Card> </Card>
); );

View File

@@ -71,7 +71,7 @@ const AdminDashboard = () => {
</div> </div>
<Link to={'/'} className=''> <Link to={'/'} className=''>
<Button <Button
className="btn-lavender mb-8 md:mb-0 " className="btn-lavender mb-8 md:mb-0 mr-4 "
> >
<Globe /> <Globe />
View Public Site 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"> <div className="space-y-6">
{/* Header and Actions */} {/* Header and Actions */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<p className="text-muted-foreground"> <div>
Configure tier names, time ranges, and badges displayed in the members directory.
</p> <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"> <div className="flex items-center gap-2">
{hasChanges && ( {hasChanges && (
<Button variant="outline" onClick={handleDiscardChanges}> <Button variant="outline" onClick={handleDiscardChanges}>

View File

@@ -51,6 +51,7 @@ import {
Zap, Zap,
Copy, Copy,
X, X,
Grip
} from 'lucide-react'; } from 'lucide-react';
// Field type icons // Field type icons
@@ -319,11 +320,11 @@ const AdminRegistrationBuilder = () => {
steps: prev.steps.map((s) => steps: prev.steps.map((s) =>
s.id === selectedStep s.id === selectedStep
? { ? {
...s, ...s,
sections: s.sections sections: s.sections
.filter((sec) => sec.id !== sectionId) .filter((sec) => sec.id !== sectionId)
.map((sec, idx) => ({ ...sec, order: idx + 1 })), .map((sec, idx) => ({ ...sec, order: idx + 1 })),
} }
: s : s
), ),
})); }));
@@ -361,13 +362,13 @@ const AdminRegistrationBuilder = () => {
steps: prev.steps.map((s) => steps: prev.steps.map((s) =>
s.id === selectedStep s.id === selectedStep
? { ? {
...s, ...s,
sections: s.sections.map((sec) => sections: s.sections.map((sec) =>
sec.id === selectedSection sec.id === selectedSection
? { ...sec, fields: [...(sec.fields || []), newField] } ? { ...sec, fields: [...(sec.fields || []), newField] }
: sec : sec
), ),
} }
: s : s
), ),
})); }));
@@ -395,18 +396,18 @@ const AdminRegistrationBuilder = () => {
steps: prev.steps.map((s) => steps: prev.steps.map((s) =>
s.id === selectedStep s.id === selectedStep
? { ? {
...s, ...s,
sections: s.sections.map((sec) => sections: s.sections.map((sec) =>
sec.id === selectedSection sec.id === selectedSection
? { ? {
...sec, ...sec,
fields: sec.fields fields: sec.fields
.filter((f) => f.id !== fieldId) .filter((f) => f.id !== fieldId)
.map((f, idx) => ({ ...f, order: idx + 1 })), .map((f, idx) => ({ ...f, order: idx + 1 })),
} }
: sec : sec
), ),
} }
: s : s
), ),
})); }));
@@ -423,18 +424,18 @@ const AdminRegistrationBuilder = () => {
steps: prev.steps.map((s) => steps: prev.steps.map((s) =>
s.id === selectedStep s.id === selectedStep
? { ? {
...s, ...s,
sections: s.sections.map((sec) => sections: s.sections.map((sec) =>
sec.id === selectedSection sec.id === selectedSection
? { ? {
...sec, ...sec,
fields: sec.fields.map((f) => fields: sec.fields.map((f) =>
f.id === fieldId ? { ...f, ...updates } : f f.id === fieldId ? { ...f, ...updates } : f
), ),
} }
: sec : sec
), ),
} }
: s : s
), ),
})); }));
@@ -492,132 +493,137 @@ const AdminRegistrationBuilder = () => {
)} )}
{/* Main Builder Layout */} {/* Main Builder Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6"> {/* Left Sidebar - Steps & Sections */}
{/* Left Sidebar - Steps & Sections */} <div className="lg:col-span-3">
<div className="lg:col-span-3"> <Card className="p-4">
<Card className="p-4"> <div className="flex justify-between items-center mb-4">
<div className="flex justify-between items-center mb-4"> <h2 className="text-lg font-semibold">Steps</h2>
<h2 className="text-lg font-semibold">Steps</h2> <Button size="sm" variant="ghost" onClick={() => setAddStepDialogOpen(true)}>
<Button size="sm" variant="ghost" onClick={() => setAddStepDialogOpen(true)}> <Plus className="h-4 w-4" />
<Plus className="h-4 w-4" /> </Button>
</Button> </div>
</div>
<div className="space-y-2"> <div className="flex space-y-2">
{sortedSteps.map((step, index) => ( {sortedSteps.map((step, index) => (
<div <div
key={step.id} key={step.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${ className={`p-3 rounded-t-lg border cursor-pointer transition-colors ${selectedStep === step.id
selectedStep === step.id ? ' bg-brand-lavender/10 border-b-4 border-b-brand-dark-lavender'
: ''
}`}
onClick={() => {
setSelectedStep(step.id);
setSelectedSection(null);
setSelectedField(null);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="font-medium">Step: </div>
<span className="font-medium text-sm">{step.title}</span>
</div>
{/* Mod Buttons */}
<div className="flex items-center gap-1">
{/* <Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleMoveStep(step.id, 'up');
}}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleMoveStep(step.id, 'down');
}}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button> */}
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover:text-white ml-2"
onClick={(e) => {
e.stopPropagation();
handleDeleteStep(step.id);
}}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
</div>
))}
</div>
{/* Sections for selected step */}
{currentStep && (
<>
<div className="flex justify-between items-center mt-6 mb-4">
<h2 className="text-lg font-semibold">Sections</h2>
<Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
{sortedSections.map((section) => (
<div
key={section.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedSection === section.id
? 'border-brand-purple bg-brand-lavender/10' ? 'border-brand-purple bg-brand-lavender/10'
: 'border-gray-200 hover:border-gray-300' : 'border-gray-200 hover:border-gray-300'
}`} }`}
onClick={() => { onClick={() => {
setSelectedStep(step.id); setSelectedSection(section.id);
setSelectedSection(null); setSelectedField(null);
setSelectedField(null); }}
}} >
> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <span className="text-sm">{section.title}</span>
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<span className="font-medium text-sm">{step.title}</span>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleMoveStep(step.id, 'up');
}}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleMoveStep(step.id, 'down');
}}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6 text-red-500 hover:text-red-700" className="h-6 w-6 text-red-500 hover:text-red-700"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteStep(step.id); handleDeleteSection(section.id);
}} }}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> </>
)}
{/* Sections for selected step */} </Card>
{currentStep && ( </div>
<> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="flex justify-between items-center mt-6 mb-4">
<h2 className="text-lg font-semibold">Sections</h2>
<Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
{sortedSections.map((section) => (
<div
key={section.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
selectedSection === section.id
? 'border-brand-purple bg-brand-lavender/10'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => {
setSelectedSection(section.id);
setSelectedField(null);
}}
>
<div className="flex items-center justify-between">
<span className="text-sm">{section.title}</span>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
handleDeleteSection(section.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</>
)}
</Card>
</div>
{/* Center - Form Canvas */} {/* Center - Form Canvas */}
<div className="lg:col-span-6"> <div className="lg:col-span-9">
<Card className="p-6"> <Card className="p-6">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold"> <div className='relative -mx-11 -my-2 flex gap-2 items-center'>
{currentStep?.title || 'Select a step'} <Grip className="size-10 text-gray-400 py-2 bg-background" />
</h2>
<h2 className="text-xl font-semibold">
{currentStep?.title || 'Select a step'}
</h2>
</div>
{selectedSection && ( {selectedSection && (
<Button size="sm" onClick={() => setAddFieldDialogOpen(true)}> <Button size="sm" onClick={() => setAddFieldDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
@@ -637,11 +643,10 @@ const AdminRegistrationBuilder = () => {
return ( return (
<div <div
key={section.id} key={section.id}
className={`mb-6 p-4 rounded-lg border-2 ${ className={`mb-6 p-4 rounded-lg border-2 ${selectedSection === section.id
selectedSection === section.id ? 'border-brand-purple'
? 'border-brand-purple' : 'border-dashed border-gray-200'
: 'border-dashed border-gray-200' }`}
}`}
onClick={() => setSelectedSection(section.id)} onClick={() => setSelectedSection(section.id)}
> >
<h3 className="text-lg font-medium mb-4">{section.title}</h3> <h3 className="text-lg font-medium mb-4">{section.title}</h3>
@@ -656,11 +661,10 @@ const AdminRegistrationBuilder = () => {
return ( return (
<div <div
key={field.id} key={field.id}
className={`p-3 rounded-lg border cursor-pointer transition-all ${ className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedField === field.id
selectedField === field.id ? 'border-brand-purple bg-brand-lavender/5 ring-2 ring-brand-purple/20'
? 'border-brand-purple bg-brand-lavender/5 ring-2 ring-brand-purple/20' : 'border-gray-200 hover:border-gray-300'
: 'border-gray-200 hover:border-gray-300' } ${field.is_fixed ? 'bg-gray-50' : ''}`}
} ${field.is_fixed ? 'bg-gray-50' : ''}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedSection(section.id); setSelectedSection(section.id);
@@ -719,23 +723,7 @@ const AdminRegistrationBuilder = () => {
)} )}
</Card> </Card>
{/* Conditional Rules */}
<Card className="p-6 mt-6">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-yellow-500" />
<h2 className="text-lg font-semibold">Conditional Rules</h2>
</div>
<Button size="sm" variant="outline" onClick={() => setConditionalDialogOpen(true)}>
<Settings className="h-4 w-4 mr-2" />
Manage Rules
</Button>
</div>
<div className="text-sm text-muted-foreground">
{schema?.conditional_rules?.length || 0} conditional rule(s) configured
</div>
</Card>
</div> </div>
{/* Right Sidebar - Field Properties */} {/* Right Sidebar - Field Properties */}
@@ -912,6 +900,23 @@ const AdminRegistrationBuilder = () => {
</div> </div>
)} )}
</Card> </Card>
{/* Conditional Rules */}
<Card className="p-6 mt-6">
<div className="flex flex-col justify-between items-center mb-4">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-yellow-500" />
<h2 className="text-lg font-semibold">Conditional Rules</h2>
</div>
<Button size="sm" variant="outline" onClick={() => setConditionalDialogOpen(true)}>
<Settings className="h-4 w-4 mr-2" />
Manage Rules
</Button>
</div>
<div className="text-sm text-muted-foreground">
{schema?.conditional_rules?.length || 0} conditional rule(s) configured
</div>
</Card>
</div> </div>
</div> </div>
@@ -1021,45 +1026,213 @@ const AdminRegistrationBuilder = () => {
{/* Conditional Rules Dialog */} {/* Conditional Rules Dialog */}
<Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}> <Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Conditional Rules</DialogTitle> <DialogTitle>Conditional Rules</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4 max-h-96 overflow-y-auto"> <div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
{(schema?.conditional_rules || []).map((rule, index) => ( <p className="text-sm text-muted-foreground">
<div key={rule.id} className="p-4 border rounded-lg space-y-3"> Rules allow you to show or hide fields based on other field values. For example, show a "Scholarship Reason" field only when "Request Scholarship" is checked.
<div className="flex justify-between items-center"> </p>
<span className="font-medium">Rule {index + 1}</span>
<Button {(schema?.conditional_rules || []).map((rule, index) => {
size="icon" // Get all available fields for dropdowns
variant="ghost" const allFields = schema?.steps?.flatMap(step =>
className="h-6 w-6 text-red-500" step.sections?.flatMap(section =>
onClick={() => { section.fields?.map(field => ({
updateSchema((prev) => ({ id: field.id,
...prev, label: field.label,
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id), type: field.type,
})); stepTitle: step.title,
}} sectionTitle: section.title,
> })) || []
<Trash2 className="h-4 w-4" /> ) || []
</Button> ) || [];
// Filter trigger fields (checkbox, dropdown, radio work best)
const triggerFields = allFields.filter(f =>
['checkbox', 'dropdown', 'radio', 'text'].includes(f.type)
);
// Update a specific rule
const updateRule = (ruleId, updates) => {
updateSchema((prev) => ({
...prev,
conditional_rules: prev.conditional_rules.map((r) =>
r.id === ruleId ? { ...r, ...updates } : r
),
}));
};
// Get the trigger field's type to determine value input
const triggerFieldData = allFields.find(f => f.id === rule.trigger_field);
return (
<div key={rule.id} className="p-4 border rounded-lg space-y-4 bg-gray-50">
<div className="flex justify-between items-center">
<span className="font-medium text-sm">Rule {index + 1}</span>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover:text-red-700"
onClick={() => {
updateSchema((prev) => ({
...prev,
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id),
}));
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Trigger Field Selection */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Label className="text-xs">When this field...</Label>
<Select
value={rule.trigger_field || ''}
onValueChange={(value) => updateRule(rule.id, { trigger_field: value })}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
{triggerFields.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.label}
<span className="text-xs text-muted-foreground ml-1">
({field.type})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Operator</Label>
<Select
value={rule.trigger_operator || 'equals'}
onValueChange={(value) => updateRule(rule.id, { trigger_operator: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">equals</SelectItem>
<SelectItem value="not_equals">not equals</SelectItem>
<SelectItem value="contains">contains</SelectItem>
<SelectItem value="not_empty">is not empty</SelectItem>
<SelectItem value="empty">is empty</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Value</Label>
{triggerFieldData?.type === 'checkbox' ? (
<Select
value={String(rule.trigger_value)}
onValueChange={(value) =>
updateRule(rule.id, { trigger_value: value === 'true' })
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Checked (true)</SelectItem>
<SelectItem value="false">Unchecked (false)</SelectItem>
</SelectContent>
</Select>
) : ['empty', 'not_empty'].includes(rule.trigger_operator) ? (
<Input
className="mt-1"
value="(no value needed)"
disabled
/>
) : (
<Input
className="mt-1"
value={rule.trigger_value || ''}
onChange={(e) => updateRule(rule.id, { trigger_value: e.target.value })}
placeholder="Enter value"
/>
)}
</div>
</div>
{/* Action Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-xs">Action</Label>
<Select
value={rule.action || 'show'}
onValueChange={(value) => updateRule(rule.id, { action: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="show">Show fields</SelectItem>
<SelectItem value="hide">Hide fields</SelectItem>
<SelectItem value="require">Make required</SelectItem>
<SelectItem value="optional">Make optional</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Target Fields</Label>
<div className="mt-1 border rounded-md p-2 bg-white max-h-32 overflow-y-auto">
{allFields.length === 0 ? (
<p className="text-xs text-muted-foreground">No fields available</p>
) : (
<div className="space-y-1">
{allFields
.filter(f => f.id !== rule.trigger_field) // Don't show trigger field as target
.map((field) => (
<div key={field.id} className="flex items-center gap-2">
<Checkbox
id={`${rule.id}-${field.id}`}
checked={(rule.target_fields || []).includes(field.id)}
onCheckedChange={(checked) => {
const currentTargets = rule.target_fields || [];
const newTargets = checked
? [...currentTargets, field.id]
: currentTargets.filter((id) => id !== field.id);
updateRule(rule.id, { target_fields: newTargets });
}}
/>
<Label
htmlFor={`${rule.id}-${field.id}`}
className="text-xs font-normal cursor-pointer"
>
{field.label}
</Label>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Rule Summary */}
{rule.trigger_field && (rule.target_fields?.length || 0) > 0 && (
<div className="text-xs bg-blue-50 border border-blue-200 rounded p-2 text-blue-800">
<strong>Summary:</strong> When "{triggerFieldData?.label || rule.trigger_field}" {rule.trigger_operator} "{String(rule.trigger_value)}", {rule.action} the following fields: {rule.target_fields?.map(id => allFields.find(f => f.id === id)?.label || id).join(', ')}
</div>
)}
</div> </div>
<div className="text-sm text-muted-foreground"> );
When <span className="font-mono bg-gray-100 px-1">{rule.trigger_field}</span>{' '} })}
{rule.trigger_operator}{' '}
<span className="font-mono bg-gray-100 px-1">{String(rule.trigger_value)}</span>,{' '}
{rule.action} fields:{' '}
<span className="font-mono bg-gray-100 px-1">
{rule.target_fields?.join(', ')}
</span>
</div>
</div>
))}
{(schema?.conditional_rules || []).length === 0 && ( {(schema?.conditional_rules || []).length === 0 && (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
No conditional rules configured. Rules allow you to show or hide fields based on No conditional rules configured yet.<br />
other field values. Click "Add Rule" to create your first rule.
</div> </div>
)} )}
@@ -1120,13 +1293,12 @@ const AdminRegistrationBuilder = () => {
.map((field) => ( .map((field) => (
<div <div
key={field.id} key={field.id}
className={`${ className={`${field.width === 'full'
field.width === 'full' ? 'col-span-2'
? 'col-span-2' : field.width === 'third'
: field.width === 'third'
? 'col-span-1' ? 'col-span-1'
: 'col-span-1' : 'col-span-1'
}`} }`}
> >
<Label> <Label>
{field.label} {field.label}

View File

@@ -16,11 +16,13 @@ export default function AdminSettings() {
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
publishable_key: '',
secret_key: '', secret_key: '',
webhook_secret: '' webhook_secret: ''
}); });
// Show/hide sensitive values // Show/hide sensitive values
const [showPublishableKey, setShowPublishableKey] = useState(false);
const [showSecretKey, setShowSecretKey] = useState(false); const [showSecretKey, setShowSecretKey] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false); const [showWebhookSecret, setShowWebhookSecret] = useState(false);
@@ -57,6 +59,7 @@ export default function AdminSettings() {
const handleEditClick = () => { const handleEditClick = () => {
setIsEditing(true); setIsEditing(true);
setFormData({ setFormData({
publishable_key: '',
secret_key: '', secret_key: '',
webhook_secret: '' webhook_secret: ''
}); });
@@ -65,17 +68,24 @@ export default function AdminSettings() {
const handleCancelEdit = () => { const handleCancelEdit = () => {
setIsEditing(false); setIsEditing(false);
setFormData({ setFormData({
publishable_key: '',
secret_key: '', secret_key: '',
webhook_secret: '' webhook_secret: ''
}); });
setShowPublishableKey(false);
setShowSecretKey(false); setShowSecretKey(false);
setShowWebhookSecret(false); setShowWebhookSecret(false);
}; };
const handleSave = async () => { const handleSave = async () => {
// Validate inputs // Validate inputs
if (!formData.secret_key || !formData.webhook_secret) { if (!formData.publishable_key || !formData.secret_key || !formData.webhook_secret) {
toast.error('Both Secret Key and Webhook Secret are required'); 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; return;
} }
@@ -89,15 +99,25 @@ export default function AdminSettings() {
return; 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); setSaving(true);
try { try {
await api.put('/admin/settings/stripe', formData); await api.put('/admin/settings/stripe', formData);
toast.success('Stripe settings updated successfully'); toast.success('Stripe settings updated successfully');
setIsEditing(false); setIsEditing(false);
setFormData({ setFormData({
publishable_key: '',
secret_key: '', secret_key: '',
webhook_secret: '' webhook_secret: ''
}); });
setShowPublishableKey(false);
setShowSecretKey(false); setShowSecretKey(false);
setShowWebhookSecret(false); setShowWebhookSecret(false);
// Refresh status // Refresh status
@@ -157,6 +177,31 @@ export default function AdminSettings() {
{isEditing ? ( {isEditing ? (
/* Edit Mode */ /* Edit Mode */
<div className="space-y-6"> <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 */} {/* Secret Key Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="secret_key">Stripe Secret Key</Label> <Label htmlFor="secret_key">Stripe Secret Key</Label>
@@ -178,7 +223,7 @@ export default function AdminSettings() {
</button> </button>
</div> </div>
<p className="text-xs text-gray-500"> <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> </p>
</div> </div>
@@ -267,7 +312,7 @@ export default function AdminSettings() {
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"> <div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div> <div>
<p className="font-semibold text-gray-900">Environment</p> <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> </div>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${ <span className={`px-3 py-1 rounded-full text-sm font-semibold ${
stripeStatus.environment === 'live' stripeStatus.environment === 'live'
@@ -278,6 +323,20 @@ export default function AdminSettings() {
</span> </span>
</div> </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 className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div> <div>
<p className="font-semibold text-gray-900">Secret Key</p> <p className="font-semibold text-gray-900">Secret Key</p>

View File

@@ -354,13 +354,7 @@ const AdminValidations = () => {
Quick Overview Quick Overview
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<StatCard
title="Total Pending"
value={loading ? '-' : pendingUsers.length}
icon={Users}
iconBgClass="text-brand-purple"
dataTestId="stat-total-users"
/>
<StatCard <StatCard
title="Awaiting Email" title="Awaiting Email"
@@ -394,7 +388,13 @@ const AdminValidations = () => {
dataTestId="stat-rejected" 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> </div>
</Card> </Card>

View File

@@ -20,6 +20,7 @@ import MemberCard from '../../components/MemberCard';
import MemberBadge from '../../components/MemberBadge'; import MemberBadge from '../../components/MemberBadge';
import useMembers from '../../hooks/use-members'; import useMembers from '../../hooks/use-members';
import useMemberTiers from '../../hooks/use-member-tiers'; import useMemberTiers from '../../hooks/use-member-tiers';
import useDirectoryConfig from '../../hooks/use-directory-config';
const MembersDirectory = () => { const MembersDirectory = () => {
const [selectedMember, setSelectedMember] = useState(null); const [selectedMember, setSelectedMember] = useState(null);
@@ -28,6 +29,7 @@ const MembersDirectory = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12; const pageSize = 12;
const { tiers } = useMemberTiers(); const { tiers } = useMemberTiers();
const { isFieldEnabled } = useDirectoryConfig();
const allowedRoles = useMemo(() => [], []); const allowedRoles = useMemo(() => [], []);
const normalizeStatus = useCallback((status) => { const normalizeStatus = useCallback((status) => {
if (typeof status === 'string') { if (typeof status === 'string') {
@@ -242,7 +244,7 @@ const MembersDirectory = () => {
{selectedMember.first_name} {selectedMember.last_name} {selectedMember.first_name} {selectedMember.last_name}
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} /> <MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
</DialogTitle> </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" }}> <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)]" /> <Heart className="h-5 w-5 text-[var(--orange-light)]" />
<span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span> <span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span>
@@ -252,7 +254,7 @@ const MembersDirectory = () => {
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{/* Bio */} {/* Bio */}
{selectedMember.directory_bio && ( {isFieldEnabled('directory_bio') && selectedMember.directory_bio && (
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
About About
@@ -264,79 +266,81 @@ const MembersDirectory = () => {
)} )}
{/* Contact Information */} {/* Contact Information */}
<div> {(isFieldEnabled('directory_email') || isFieldEnabled('directory_phone') || isFieldEnabled('directory_address') || isFieldEnabled('directory_dob')) && (
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}> <div>
Contact Information <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
</h3> Contact Information
<div className="space-y-3"> </h3>
{selectedMember.directory_email && ( <div className="space-y-3">
<div className="flex items-center gap-3"> {isFieldEnabled('directory_email') && selectedMember.directory_email && (
<div className="p-2 rounded-lg bg-[var(--lavender-500)]"> <div className="flex items-center gap-3">
<Mail className="h-5 w-5 text-brand-purple " /> <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>
<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 && ( {isFieldEnabled('directory_phone') && selectedMember.directory_phone && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]"> <div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Phone className="h-5 w-5 text-brand-purple " /> <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>
<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 && ( {isFieldEnabled('directory_address') && selectedMember.directory_address && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]"> <div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<MapPin className="h-5 w-5 text-brand-purple " /> <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>
<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 && ( {isFieldEnabled('directory_dob') && selectedMember.directory_dob && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]"> <div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Heart className="h-5 w-5 text-[var(--orange-light)]" /> <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>
<div> )}
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Birthday</p> </div>
<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 */} {/* Volunteer Interests */}
{selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && ( {isFieldEnabled('volunteer_interests') && selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests Volunteer Interests
@@ -355,7 +359,7 @@ const MembersDirectory = () => {
)} )}
{/* Social Media */} {/* 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) && ( selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}> <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) => { async (error) => {
const config = error.config; 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) // 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)) { if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
console.error('[API] Request failed:', { console.error('[API] Request failed:', {