10 Commits

Author SHA1 Message Date
kayela
79a727e7ba Merge branch 'dev' into features 2026-02-04 11:47:55 -06:00
kayela
96e4b711a8 styling 2026-02-04 11:44:50 -06:00
kayela
ced8d75bcc updated registration screen 2026-02-04 10:42:07 -06:00
08c8dd3913 Frontend Upload Improvements
Solution: Updated frontend/src/components/ComprehensiveImportWizard.js:
  - Increased timeout from 30s to 120s for large file uploads + R2 storage
  - Added console error logging for debugging

Login Session Timeout Fix

1. frontend/src/utils/api.js
  - Added BASENAME constant from environment variable
  - Updated the 401 redirect to use ${BASENAME}/login?session=expired instead of just /login?session=expired

  2. frontend/src/pages/Login.js
  - Added basename constant from environment variable
  - Updated URL cleanup to use ${basename}/login instead of just /login
2026-02-04 22:52:09 +07:00
kayela
f0ee505339 restructured layout 2026-02-02 16:36:52 -06: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
22 changed files with 4054 additions and 674 deletions

View File

@@ -1,3 +1,3 @@
REACT_APP_BACKEND_URL=http://localhost:8000 REACT_APP_BACKEND_URL=http://localhost:8000
REACT_APP_BASENAME=/membership REACT_APP_BASENAME=/
PUBLIC_URL=/membership PUBLIC_URL=/

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 */}

File diff suppressed because it is too large Load Diff

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 = () => {

File diff suppressed because it is too large Load Diff

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

@@ -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,13 +13,40 @@ 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 basename = process.env.REACT_APP_BASENAME || '';
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', email: '',
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 (respect basename for subpath deployments)
window.history.replaceState({}, '', `${basename}/login`);
} else if (sessionParam === 'idle') {
toast.info('You were logged out due to inactivity. Please log in again.', {
duration: 5000,
});
// Clean up URL (respect basename for subpath deployments)
window.history.replaceState({}, '', `${basename}/login`);
} else if (stateMessage) {
toast.info(stateMessage, {
duration: 5000,
});
}
}, [searchParams, location.state, basename]);
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,6 +429,7 @@ const Profile = () => {
</div> </div>
{/* Member Directory Settings */} {/* Member Directory Settings */}
{isFieldEnabled('show_in_directory') && (
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4"> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<BookUser className="h-5 w-5 text-[var(--orange-light)]" /> <BookUser className="h-5 w-5 text-[var(--orange-light)]" />
@@ -452,6 +455,7 @@ const Profile = () => {
{formData.show_in_directory && ( {formData.show_in_directory && (
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]"> <div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
{isFieldEnabled('directory_email') && (
<div> <div>
<Label htmlFor="directory_email">Directory Email</Label> <Label htmlFor="directory_email">Directory Email</Label>
<Input <Input
@@ -464,7 +468,9 @@ const Profile = () => {
placeholder="Optional - email to show in directory" placeholder="Optional - email to show in directory"
/> />
</div> </div>
)}
{isFieldEnabled('directory_bio') && (
<div> <div>
<Label htmlFor="directory_bio">Bio</Label> <Label htmlFor="directory_bio">Bio</Label>
<Textarea <Textarea
@@ -476,7 +482,9 @@ const Profile = () => {
placeholder="Tell other members about yourself..." placeholder="Tell other members about yourself..."
/> />
</div> </div>
)}
{isFieldEnabled('directory_address') && (
<div> <div>
<Label htmlFor="directory_address">Address</Label> <Label htmlFor="directory_address">Address</Label>
<Input <Input
@@ -488,7 +496,9 @@ const Profile = () => {
placeholder="Optional - address to show in directory" placeholder="Optional - address to show in directory"
/> />
</div> </div>
)}
{isFieldEnabled('directory_phone') && (
<div> <div>
<Label htmlFor="directory_phone">Phone</Label> <Label htmlFor="directory_phone">Phone</Label>
<Input <Input
@@ -501,7 +511,9 @@ const Profile = () => {
placeholder="Optional - phone to show in directory" placeholder="Optional - phone to show in directory"
/> />
</div> </div>
)}
{isFieldEnabled('directory_dob') && (
<div> <div>
<Label htmlFor="directory_dob">Date of Birth</Label> <Label htmlFor="directory_dob">Date of Birth</Label>
<Input <Input
@@ -513,7 +525,9 @@ const Profile = () => {
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/> />
</div> </div>
)}
{isFieldEnabled('directory_partner_name') && (
<div> <div>
<Label htmlFor="directory_partner_name">Partner Name</Label> <Label htmlFor="directory_partner_name">Partner Name</Label>
<Input <Input
@@ -525,9 +539,11 @@ const Profile = () => {
placeholder="Optional - partner name to show in directory" placeholder="Optional - partner name to show in directory"
/> />
</div> </div>
)}
</div> </div>
)} )}
</div> </div>
)}
</Card> </Card>
); );
@@ -664,6 +680,7 @@ const Profile = () => {
</div> </div>
{/* Volunteer Interests */} {/* Volunteer Interests */}
{isFieldEnabled('volunteer_interests') && (
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4"> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Users className="h-5 w-5 text-brand-purple" /> <Users className="h-5 w-5 text-brand-purple" />
@@ -692,6 +709,7 @@ const Profile = () => {
))} ))}
</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">
<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"> <p className="text-muted-foreground">
Configure tier names, time ranges, and badges displayed in the members directory. Configure tier names, time ranges, and badges displayed in the members directory.
</p> </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

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
@@ -6,19 +6,24 @@ import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { Checkbox } from '../../components/ui/checkbox';
import { Badge } from '../../components/ui/badge';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator,
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus } from 'lucide-react'; import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus, KeyRound, Loader2, X } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog'; import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteMemberDialog from '../../components/InviteMemberDialog'; import InviteMemberDialog from '../../components/InviteMemberDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard'; import WordPressImportWizard from '../../components/WordPressImportWizard';
import ComprehensiveImportWizard from '../../components/ComprehensiveImportWizard';
import TemplateImportWizard from '../../components/TemplateImportWizard';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard'; import { StatCard } from '@/components/StatCard';
import { useMembers } from '../../hooks/use-users'; import { useMembers } from '../../hooks/use-users';
@@ -45,8 +50,158 @@ const AdminMembers = () => {
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [comprehensiveImportOpen, setComprehensiveImportOpen] = useState(false);
const [templateImportOpen, setTemplateImportOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
// Bulk selection state
const [selectedUsers, setSelectedUsers] = useState(new Set());
const [bulkActionLoading, setBulkActionLoading] = useState(false);
const [bulkPasswordResetOpen, setBulkPasswordResetOpen] = useState(false);
// Import job filter state
const [importJobs, setImportJobs] = useState([]);
const [selectedImportJob, setSelectedImportJob] = useState(null);
const [importJobLoading, setImportJobLoading] = useState(false);
// Fetch import jobs on mount
useEffect(() => {
const fetchImportJobs = async () => {
try {
const response = await api.get('/admin/users/import-jobs');
// Filter to only show completed/partial jobs that have users
const jobsWithUsers = response.data.filter(
(job) => job.successful_rows > 0 && ['completed', 'partial'].includes(job.status)
);
setImportJobs(jobsWithUsers);
} catch (error) {
console.error('Failed to fetch import jobs:', error);
}
};
if (hasPermission('users.import')) {
fetchImportJobs();
}
}, [hasPermission]);
// Select all users from an import job
const selectUsersFromImportJob = useCallback(async (jobId) => {
if (!jobId) {
setSelectedImportJob(null);
return;
}
setImportJobLoading(true);
setSelectedImportJob(jobId);
try {
// Get import job details to get the user IDs
const response = await api.get(`/admin/users/import-jobs/${jobId}`);
const importedUserIds = response.data.imported_user_ids || [];
if (importedUserIds.length === 0) {
toast.info('No users found in this import job');
setSelectedImportJob(null);
return;
}
// Filter to only select users that are currently visible in filteredUsers
const visibleImportedUsers = filteredUsers.filter((user) =>
importedUserIds.includes(user.id)
);
if (visibleImportedUsers.length === 0) {
// If no visible users match, select from all users
const allImportedUsers = users.filter((user) =>
importedUserIds.includes(user.id)
);
if (allImportedUsers.length > 0) {
setSelectedUsers(new Set(allImportedUsers.map((u) => u.id)));
toast.success(
`Selected ${allImportedUsers.length} users from import job (some may be hidden by current filters)`
);
} else {
toast.info('No users from this import job found');
}
} else {
setSelectedUsers(new Set(visibleImportedUsers.map((u) => u.id)));
toast.success(`Selected ${visibleImportedUsers.length} users from import job`);
}
} catch (error) {
const message = error.response?.data?.detail || 'Failed to load import job';
toast.error(message);
setSelectedImportJob(null);
} finally {
setImportJobLoading(false);
}
}, [filteredUsers, users]);
// Check if all visible users are selected
const allSelected = useMemo(() => {
if (!filteredUsers || filteredUsers.length === 0) return false;
return filteredUsers.every((user) => selectedUsers.has(user.id));
}, [filteredUsers, selectedUsers]);
// Toggle single user selection
const toggleUserSelection = (userId) => {
setSelectedUsers((prev) => {
const newSet = new Set(prev);
if (newSet.has(userId)) {
newSet.delete(userId);
} else {
newSet.add(userId);
}
return newSet;
});
};
// Toggle all visible users
const toggleAllUsers = () => {
if (allSelected) {
setSelectedUsers(new Set());
} else {
setSelectedUsers(new Set(filteredUsers.map((user) => user.id)));
}
};
// Clear selection
const clearSelection = () => {
setSelectedUsers(new Set());
};
// Handle bulk password reset
const handleBulkPasswordReset = async () => {
if (selectedUsers.size === 0) {
toast.error('No users selected');
return;
}
setBulkActionLoading(true);
try {
const response = await api.post('/admin/users/bulk-password-reset', {
user_ids: Array.from(selectedUsers),
send_email: true,
});
toast.success(
`Password reset emails sent to ${response.data.successful} users`
);
if (response.data.failed > 0) {
toast.warning(`${response.data.failed} emails failed to send`);
}
setBulkPasswordResetOpen(false);
clearSelection();
} catch (error) {
const message = error.response?.data?.detail || 'Failed to send password reset emails';
toast.error(message);
} finally {
setBulkActionLoading(false);
}
};
const handleActivatePayment = (user) => { const handleActivatePayment = (user) => {
setSelectedUserForPayment(user); setSelectedUserForPayment(user);
setPaymentDialogOpen(true); setPaymentDialogOpen(true);
@@ -232,13 +387,54 @@ const AdminMembers = () => {
)} )}
{hasPermission('users.import') && ( {hasPermission('users.import') && (
<Button <DropdownMenu>
onClick={() => setImportDialogOpen(true)} <DropdownMenuTrigger asChild>
className="btn-util-green " <Button className="btn-util-green">
>
<Upload className="h-5 w-5 mr-2" /> <Upload className="h-5 w-5 mr-2" />
Import Import
<ChevronDown className="h-4 w-4 ml-2" />
</Button> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem
onClick={() => setTemplateImportOpen(true)}
className="cursor-pointer"
>
<FileDown className="h-4 w-4 mr-2" />
<div>
<span className="font-medium">Template Import (Recommended)</span>
<p className="text-xs text-muted-foreground">
Download templates, fill your data, upload
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setComprehensiveImportOpen(true)}
className="cursor-pointer"
>
<Upload className="h-4 w-4 mr-2" />
<div>
<span className="font-medium">WordPress Import</span>
<p className="text-xs text-muted-foreground">
For WordPress/PMS exports
</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setImportDialogOpen(true)}
className="cursor-pointer"
>
<Users className="h-4 w-4 mr-2" />
<div>
<span className="font-medium">Basic User Import</span>
<p className="text-xs text-muted-foreground">
Simple WordPress users CSV
</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)} )}
{hasPermission('users.invite') && ( {hasPermission('users.invite') && (
@@ -331,6 +527,102 @@ const AdminMembers = () => {
</div> </div>
</Card> </Card>
{/* Import Job Quick Select */}
{hasPermission('users.import') && importJobs.length > 0 && (
<Card className="p-4 mb-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<Upload className="h-5 w-5 text-brand-purple" />
<span
className="font-semibold text-brand-purple"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Quick Select from Import
</span>
</div>
<div className="flex items-center gap-3">
<Select
value={selectedImportJob || ''}
onValueChange={(value) => selectUsersFromImportJob(value || null)}
>
<SelectTrigger className="w-64 h-10 bg-white border-[var(--neutral-800)]">
<SelectValue placeholder="Select an import job..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="">-- None --</SelectItem>
{importJobs.map((job) => (
<SelectItem key={job.id} value={job.id}>
<div className="flex flex-col">
<span className="font-medium">
{job.filename || 'Import'} ({job.successful_rows} users)
</span>
<span className="text-xs text-muted-foreground">
{new Date(job.started_at).toLocaleDateString()}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{importJobLoading && (
<Loader2 className="h-5 w-5 animate-spin text-brand-purple" />
)}
</div>
</div>
</Card>
)}
{/* Bulk Action Bar */}
{selectedUsers.size > 0 && (
<Card className="p-4 mb-4 bg-brand-purple text-white rounded-xl sticky top-4 z-10 shadow-lg">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<span className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} selected
{selectedImportJob && (
<span className="ml-2 text-sm font-normal opacity-80">
(from import job)
</span>
)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
clearSelection();
setSelectedImportJob(null);
}}
className="text-white hover:bg-white/20"
>
<X className="h-4 w-4 mr-1" />
Clear
</Button>
</div>
<div className="flex items-center gap-2">
{hasPermission('users.reset_password') && (
<Button
variant="secondary"
size="sm"
onClick={() => setBulkPasswordResetOpen(true)}
disabled={bulkActionLoading}
className="bg-white text-brand-purple hover:bg-white/90"
>
{bulkActionLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<KeyRound className="h-4 w-4 mr-2" />
)}
Send Password Reset
</Button>
)}
</div>
</div>
</Card>
)}
{/* Members List */} {/* Members List */}
{loading ? ( {loading ? (
<div className="text-center py-20"> <div className="text-center py-20">
@@ -338,17 +630,50 @@ const AdminMembers = () => {
</div> </div>
) : filteredUsers.length > 0 ? ( ) : filteredUsers.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{/* Select All Row */}
{filteredUsers.length > 0 && hasPermission('users.reset_password') && (
<div className="flex items-center gap-3 px-2">
<Checkbox
id="select-all"
checked={allSelected}
onCheckedChange={toggleAllUsers}
/>
<label
htmlFor="select-all"
className="text-sm text-brand-purple cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Select all ({filteredUsers.length} users)
</label>
</div>
)}
{filteredUsers.map((user) => { {filteredUsers.map((user) => {
const joinedDate = user.created_at; const joinedDate = user.created_at;
const memberDate = user.member_since; const memberDate = user.member_since;
return ( return (
<Card <Card
key={user.id} key={user.id}
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow" className={`p-6 bg-background rounded-2xl border hover:shadow-md transition-shadow ${
selectedUsers.has(user.id)
? 'border-brand-purple bg-[var(--lavender-500)]'
: 'border-[var(--neutral-800)]'
}`}
data-testid={`member-card-${user.id}`} data-testid={`member-card-${user.id}`}
> >
<div className="flex justify-between items-start flex-wrap gap-4"> <div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1"> <div className="flex items-start gap-4 flex-1">
{/* Selection Checkbox */}
{hasPermission('users.reset_password') && (
<div className="flex items-center pt-1">
<Checkbox
checked={selectedUsers.has(user.id)}
onCheckedChange={() => toggleUserSelection(user.id)}
aria-label={`Select ${user.first_name} ${user.last_name}`}
/>
</div>
)}
{/* Avatar */} {/* Avatar */}
<div className="h-14 w-14 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold text-lg flex-shrink-0"> <div className="h-14 w-14 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold text-lg flex-shrink-0">
{user.first_name?.[0]}{user.last_name?.[0]} {user.first_name?.[0]}{user.last_name?.[0]}
@@ -532,6 +857,50 @@ const AdminMembers = () => {
onOpenChange={setImportDialogOpen} onOpenChange={setImportDialogOpen}
onSuccess={refetch} onSuccess={refetch}
/> />
<ComprehensiveImportWizard
open={comprehensiveImportOpen}
onOpenChange={setComprehensiveImportOpen}
onSuccess={refetch}
/>
<TemplateImportWizard
open={templateImportOpen}
onOpenChange={setTemplateImportOpen}
onSuccess={refetch}
/>
{/* Bulk Password Reset Confirmation Dialog */}
<ConfirmationDialog
open={bulkPasswordResetOpen}
onOpenChange={setBulkPasswordResetOpen}
onConfirm={handleBulkPasswordReset}
title="Send Password Reset Emails"
description={
<div className="space-y-4">
<p>
You are about to send password reset emails to{' '}
<strong>{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''}</strong>.
</p>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>What happens:</strong>
</p>
<ul className="text-sm text-blue-700 mt-2 space-y-1 list-disc list-inside">
<li>Each user will receive an email with a password reset link</li>
<li>The <code className="bg-blue-100 px-1 rounded">force_password_change</code> flag will be set</li>
<li>Users must set a new password on their next login</li>
</ul>
</div>
<p className="text-sm text-muted-foreground">
This action cannot be undone. Continue?
</p>
</div>
}
confirmText={bulkActionLoading ? 'Sending...' : 'Send Emails'}
variant="info"
loading={bulkActionLoading}
/>
</> </>
); );
}; };

View File

@@ -19,12 +19,6 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from '../../components/ui/dialog'; } from '../../components/ui/dialog';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../../components/ui/accordion';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api from '../../utils/api'; import api from '../../utils/api';
import { import {
@@ -45,13 +39,9 @@ import {
AlignLeft, AlignLeft,
Upload, Upload,
Lock, Lock,
ChevronUp,
ChevronDown,
Settings, Settings,
Zap, Zap,
Copy, X
X,
Grip
} from 'lucide-react'; } from 'lucide-react';
// Field type icons // Field type icons
@@ -192,6 +182,7 @@ const AdminRegistrationBuilder = () => {
// Get current step // Get current step
const currentStep = sortedSteps.find((s) => s.id === selectedStep); const currentStep = sortedSteps.find((s) => s.id === selectedStep);
const currentStepIndex = sortedSteps.findIndex((s) => s.id === selectedStep);
// Get sections for current step // Get sections for current step
const sortedSections = currentStep?.sections?.sort((a, b) => a.order - b.order) || []; const sortedSections = currentStep?.sections?.sort((a, b) => a.order - b.order) || [];
@@ -335,13 +326,13 @@ const AdminRegistrationBuilder = () => {
}; };
// Add field // Add field
const handleAddField = () => { const handleAddField = (sectionId = selectedSection) => {
if (!selectedSection) { if (!sectionId) {
toast.error('Please select a section first'); toast.error('Please select a section first');
return; return;
} }
const section = currentStep?.sections?.find((s) => s.id === selectedSection); const section = currentStep?.sections?.find((s) => s.id === sectionId);
const fieldCount = section?.fields?.length || 0; const fieldCount = section?.fields?.length || 0;
const newField = { const newField = {
@@ -364,7 +355,7 @@ const AdminRegistrationBuilder = () => {
? { ? {
...s, ...s,
sections: s.sections.map((sec) => sections: s.sections.map((sec) =>
sec.id === selectedSection sec.id === sectionId
? { ...sec, fields: [...(sec.fields || []), newField] } ? { ...sec, fields: [...(sec.fields || []), newField] }
: sec : sec
), ),
@@ -373,13 +364,14 @@ const AdminRegistrationBuilder = () => {
), ),
})); }));
setSelectedSection(sectionId);
setSelectedField(newField.id); setSelectedField(newField.id);
setAddFieldDialogOpen(false); setAddFieldDialogOpen(false);
}; };
// Delete field // Delete field
const handleDeleteField = (fieldId) => { const handleDeleteField = (sectionId, fieldId) => {
const section = currentStep?.sections?.find((s) => s.id === selectedSection); const section = currentStep?.sections?.find((s) => s.id === sectionId);
const field = section?.fields?.find((f) => f.id === fieldId); const field = section?.fields?.find((f) => f.id === fieldId);
if (field?.is_fixed) { if (field?.is_fixed) {
@@ -398,7 +390,7 @@ const AdminRegistrationBuilder = () => {
? { ? {
...s, ...s,
sections: s.sections.map((sec) => sections: s.sections.map((sec) =>
sec.id === selectedSection sec.id === sectionId
? { ? {
...sec, ...sec,
fields: sec.fields fields: sec.fields
@@ -418,7 +410,7 @@ const AdminRegistrationBuilder = () => {
}; };
// Update field // Update field
const handleUpdateField = (fieldId, updates) => { const handleUpdateField = (sectionId, fieldId, updates) => {
updateSchema((prev) => ({ updateSchema((prev) => ({
...prev, ...prev,
steps: prev.steps.map((s) => steps: prev.steps.map((s) =>
@@ -426,7 +418,7 @@ const AdminRegistrationBuilder = () => {
? { ? {
...s, ...s,
sections: s.sections.map((sec) => sections: s.sections.map((sec) =>
sec.id === selectedSection sec.id === sectionId
? { ? {
...sec, ...sec,
fields: sec.fields.map((f) => fields: sec.fields.map((f) =>
@@ -441,10 +433,7 @@ const AdminRegistrationBuilder = () => {
})); }));
}; };
// Get selected field data
const selectedFieldData = currentStep?.sections
?.find((s) => s.id === selectedSection)
?.fields?.find((f) => f.id === selectedField);
if (loading) { if (loading) {
return ( return (
@@ -493,23 +482,24 @@ const AdminRegistrationBuilder = () => {
)} )}
{/* Main Builder Layout */} {/* Main Builder Layout */}
{/* Left Sidebar - Steps & Sections */} <div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-3"> <div className="lg:col-span-9">
<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" className="w-32" onClick={() => setAddStepDialogOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Step
</Button> </Button>
</div> </div>
<div className="flex space-y-2"> <div className="flex flex-wrap items-end gap-2 border-b border-gray-200">
{sortedSteps.map((step, index) => ( {sortedSteps.map((step, index) => (
<div <div
key={step.id} key={step.id}
className={`p-3 rounded-t-lg border cursor-pointer transition-colors ${selectedStep === step.id className={`px-3 py-2 rounded-t-lg border cursor-pointer transition-colors ${selectedStep === step.id
? ' bg-brand-lavender/10 border-b-4 border-b-brand-dark-lavender' ? 'bg-white border-gray-300 border-b-white'
: '' : 'bg-gray-50 border-transparent hover:bg-gray-100'
}`} }`}
onClick={() => { onClick={() => {
setSelectedStep(step.id); setSelectedStep(step.id);
@@ -517,41 +507,15 @@ const AdminRegistrationBuilder = () => {
setSelectedField(null); setSelectedField(null);
}} }}
> >
<div className="flex items-center justify-between"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium">Step: </div> <span className="font-medium">Step {index + 1}</span>
<span className="font-medium text-sm">{step.title}</span> {/* <span className="text-sm text-muted-foreground">{step.title || 'Untitled Step'}</span> */}
</div> </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 <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6" className="h-6 w-6 text-red-500 hover:text-background"
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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteStep(step.id); handleDeleteStep(step.id);
@@ -561,25 +525,41 @@ const AdminRegistrationBuilder = () => {
</Button> </Button>
</div> </div>
</div> </div>
</div>
))} ))}
{sortedSteps.length === 0 && (
<div className="text-center py-6 text-muted-foreground">
No steps yet. Click "Add Step" to get started.
</div>
)}
</div> </div>
{/* Sections for selected step */} {/* Sections for selected step */}
{currentStep && ( {currentStep && (
<> <div className="mt-6">
<div className="flex justify-between items-center mt-6 mb-4"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">Sections</h2> <div>
<h2 className="text-xl font-semibold">
Step {currentStepIndex + 1}: {currentStep?.title || 'Untitled Step'}
</h2>
{currentStep.description && (
<p className="text-sm text-muted-foreground mt-1">{currentStep.description}</p>
)}
</div>
<Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}> <Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Section
</Button> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-4">
{sortedSections.map((section) => ( {sortedSections.map((section) => {
const sortedFields = section.fields?.sort((a, b) => a.order - b.order) || [];
return (
<div <div
key={section.id} key={section.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedSection === section.id className={`p-6 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'
}`} }`}
@@ -588,12 +568,17 @@ const AdminRegistrationBuilder = () => {
setSelectedField(null); setSelectedField(null);
}} }}
> >
<div className="flex items-center justify-between"> <div className="flex items-start justify-between gap-3">
<span className="text-sm">{section.title}</span> <div>
<h3 className="text-lg font-medium">{section.title || 'Untitled Section'}</h3>
{section.description && (
<p className="text-sm text-muted-foreground mt-1">{section.description}</p>
)}
</div>
<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-background"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteSection(section.id); handleDeleteSection(section.id);
@@ -602,62 +587,12 @@ const AdminRegistrationBuilder = () => {
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
</div>
))}
</div>
</>
)}
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Center - Form Canvas */}
<div className="lg:col-span-9">
<Card className="p-6">
<div className="flex justify-between items-center mb-6">
<div className='relative -mx-11 -my-2 flex gap-2 items-center'>
<Grip className="size-10 text-gray-400 py-2 bg-background" />
<h2 className="text-xl font-semibold">
{currentStep?.title || 'Select a step'}
</h2>
</div>
{selectedSection && (
<Button size="sm" onClick={() => setAddFieldDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Field
</Button>
)}
</div>
{currentStep?.description && (
<p className="text-muted-foreground mb-6">{currentStep.description}</p>
)}
{/* Sections and Fields */}
{sortedSections.map((section) => {
const sortedFields = section.fields?.sort((a, b) => a.order - b.order) || [];
return (
<div
key={section.id}
className={`mb-6 p-4 rounded-lg border-2 ${selectedSection === section.id
? 'border-brand-purple'
: 'border-dashed border-gray-200'
}`}
onClick={() => setSelectedSection(section.id)}
>
<h3 className="text-lg font-medium mb-4">{section.title}</h3>
{section.description && (
<p className="text-sm text-muted-foreground mb-4">{section.description}</p>
)}
{/* Fields */} {/* Fields */}
<div className="space-y-3"> <div className="mt-4 space-y-3">
{sortedFields.map((field) => { {sortedFields.map((field) => {
const IconComponent = FIELD_TYPE_ICONS[field.type] || Type; const IconComponent = FIELD_TYPE_ICONS[field.type] || Type;
const options = field.options || [];
return ( return (
<div <div
key={field.id} key={field.id}
@@ -692,73 +627,40 @@ const AdminRegistrationBuilder = () => {
<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-background"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteField(field.id); handleDeleteField(section.id, field.id);
}} }}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
)} )}
</div> </div>
</div>
);
})}
{sortedFields.length === 0 && ( {selectedField === field.id && (
<div className="text-center py-8 text-muted-foreground"> <div className="mt-4 border-t pt-4 space-y-4">
No fields in this section. Click "Add Field" to add one.
</div>
)}
</div>
</div>
);
})}
{sortedSections.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
No sections in this step. Add a section from the left sidebar.
</div>
)}
</Card>
</div>
{/* Right Sidebar - Field Properties */}
<div className="lg:col-span-3">
<Card className="p-4">
<h2 className="text-lg font-semibold mb-4">Field Properties</h2>
{selectedFieldData ? (
<div className="space-y-4">
{/* Field ID */}
<div> <div>
<Label className="text-xs text-muted-foreground">Field ID</Label> <Label className="text-xs text-muted-foreground">Field ID</Label>
<div className="text-sm font-mono bg-gray-100 px-2 py-1 rounded"> <div className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
{selectedFieldData.id} {field.id}
</div> </div>
</div> </div>
{/* Label */}
<div> <div>
<Label htmlFor="field-label">Label</Label> <Label htmlFor={`field-label-${field.id}`}>Label</Label>
<Input <Input
id="field-label" id={`field-label-${field.id}`}
value={selectedFieldData.label} value={field.label}
onChange={(e) => handleUpdateField(selectedField, { label: e.target.value })} onChange={(e) => handleUpdateField(section.id, field.id, { label: e.target.value })}
disabled={selectedFieldData.is_fixed} disabled={field.is_fixed}
/> />
</div> </div>
{/* Type */}
<div> <div>
<Label>Type</Label> <Label>Type</Label>
<Select <Select
value={selectedFieldData.type} value={field.type}
onValueChange={(value) => handleUpdateField(selectedField, { type: value })} onValueChange={(value) => handleUpdateField(section.id, field.id, { type: value })}
disabled={selectedFieldData.is_fixed} disabled={field.is_fixed}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -772,26 +674,22 @@ const AdminRegistrationBuilder = () => {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Required */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="field-required" id={`field-required-${field.id}`}
checked={selectedFieldData.required} checked={field.required}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleUpdateField(selectedField, { required: checked }) handleUpdateField(section.id, field.id, { required: checked })
} }
disabled={selectedFieldData.is_fixed} disabled={field.is_fixed}
/> />
<Label htmlFor="field-required">Required</Label> <Label htmlFor={`field-required-${field.id}`}>Required</Label>
</div> </div>
{/* Width */}
<div> <div>
<Label>Width</Label> <Label>Width</Label>
<Select <Select
value={selectedFieldData.width || 'full'} value={field.width || 'full'}
onValueChange={(value) => handleUpdateField(selectedField, { width: value })} onValueChange={(value) => handleUpdateField(section.id, field.id, { width: value })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -804,37 +702,35 @@ const AdminRegistrationBuilder = () => {
</Select> </Select>
</div> </div>
{/* Placeholder */} {['text', 'email', 'phone', 'textarea'].includes(field.type) && (
{['text', 'email', 'phone', 'textarea'].includes(selectedFieldData.type) && (
<div> <div>
<Label htmlFor="field-placeholder">Placeholder</Label> <Label htmlFor={`field-placeholder-${field.id}`}>Placeholder</Label>
<Input <Input
id="field-placeholder" id={`field-placeholder-${field.id}`}
value={selectedFieldData.placeholder || ''} value={field.placeholder || ''}
onChange={(e) => onChange={(e) =>
handleUpdateField(selectedField, { placeholder: e.target.value }) handleUpdateField(section.id, field.id, { placeholder: e.target.value })
} }
/> />
</div> </div>
)} )}
{/* Options for dropdown/radio/multiselect */} {['dropdown', 'radio', 'multiselect'].includes(field.type) && (
{['dropdown', 'radio', 'multiselect'].includes(selectedFieldData.type) && (
<div> <div>
<Label>Options</Label> <Label>Options</Label>
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
{(selectedFieldData.options || []).map((option, idx) => ( {options.map((option, idx) => (
<div key={idx} className="flex items-center gap-2"> <div key={idx} className="flex items-center gap-2">
<Input <Input
value={option.label} value={option.label}
onChange={(e) => { onChange={(e) => {
const newOptions = [...selectedFieldData.options]; const newOptions = [...options];
newOptions[idx] = { newOptions[idx] = {
...newOptions[idx], ...newOptions[idx],
label: e.target.value, label: e.target.value,
value: e.target.value.toLowerCase().replace(/\s+/g, '_'), value: e.target.value.toLowerCase().replace(/\s+/g, '_'),
}; };
handleUpdateField(selectedField, { options: newOptions }); handleUpdateField(section.id, field.id, { options: newOptions });
}} }}
placeholder="Option label" placeholder="Option label"
/> />
@@ -843,10 +739,8 @@ const AdminRegistrationBuilder = () => {
variant="ghost" variant="ghost"
className="h-8 w-8" className="h-8 w-8"
onClick={() => { onClick={() => {
const newOptions = selectedFieldData.options.filter( const newOptions = options.filter((_, i) => i !== idx);
(_, i) => i !== idx handleUpdateField(section.id, field.id, { options: newOptions });
);
handleUpdateField(selectedField, { options: newOptions });
}} }}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -858,10 +752,10 @@ const AdminRegistrationBuilder = () => {
variant="outline" variant="outline"
onClick={() => { onClick={() => {
const newOptions = [ const newOptions = [
...(selectedFieldData.options || []), ...options,
{ value: `option_${Date.now()}`, label: 'New Option' }, { value: `option_${Date.now()}`, label: 'New Option' },
]; ];
handleUpdateField(selectedField, { options: newOptions }); handleUpdateField(section.id, field.id, { options: newOptions });
}} }}
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
@@ -871,37 +765,70 @@ const AdminRegistrationBuilder = () => {
</div> </div>
)} )}
{/* Mapping */}
<div> <div>
<Label htmlFor="field-mapping">Database Mapping</Label> <Label htmlFor={`field-mapping-${field.id}`}>Database Mapping</Label>
<Input <Input
id="field-mapping" id={`field-mapping-${field.id}`}
value={selectedFieldData.mapping || ''} value={field.mapping || ''}
onChange={(e) => onChange={(e) =>
handleUpdateField(selectedField, { mapping: e.target.value }) handleUpdateField(section.id, field.id, { mapping: e.target.value })
} }
placeholder="Leave empty for custom data" placeholder="Leave empty for custom data"
disabled={selectedFieldData.is_fixed} disabled={field.is_fixed}
/> />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Maps to User model field. Empty = stored in custom_registration_data Maps to User model field. Empty = stored in custom_registration_data
</p> </p>
</div> </div>
{selectedFieldData.is_fixed && ( {field.is_fixed && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-yellow-800 text-sm"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-yellow-800 text-sm">
This is a fixed field and cannot be removed or have its core properties changed. This is a fixed field and cannot be removed or have its core properties changed.
</div> </div>
)} )}
</div> </div>
) : ( )}
<div className="text-center py-8 text-muted-foreground"> </div>
Select a field to edit its properties );
})}
{sortedFields.length === 0 && (
<div className="text-center py-6 text-muted-foreground">
No fields in this section. Click "Add Field" to add one.
</div>
)}
</div>
<Button
size="sm"
className="w-full mt-4"
onClick={(e) => {
e.stopPropagation();
setSelectedSection(section.id);
setSelectedField(null);
setAddFieldDialogOpen(true);
}}
>
<Plus className="size-4 mr-2" />
Add Field
</Button>
</div>
);
})}
{sortedSections.length === 0 && (
<div className="text-center py-6 text-muted-foreground">
No sections yet. Click "Add Section" to get started.
</div>
)}
</div>
</div> </div>
)} )}
</Card> </Card>
{/* Conditional Rules */} </div>
<Card className="p-6 mt-6">
<div className="lg:col-span-3">
<Card className="p-6">
<div className="flex flex-col justify-between items-center mb-4"> <div className="flex flex-col justify-between items-center mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-yellow-500" /> <Zap className="h-5 w-5 text-yellow-500" />
@@ -921,6 +848,7 @@ const AdminRegistrationBuilder = () => {
</div> </div>
{/* Add Step Dialog */} {/* Add Step Dialog */}
<Dialog open={addStepDialogOpen} onOpenChange={setAddStepDialogOpen}> <Dialog open={addStepDialogOpen} onOpenChange={setAddStepDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -1026,19 +954,55 @@ 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.
</p>
{(schema?.conditional_rules || []).map((rule, index) => {
// Get all available fields for dropdowns
const allFields = schema?.steps?.flatMap(step =>
step.sections?.flatMap(section =>
section.fields?.map(field => ({
id: field.id,
label: field.label,
type: field.type,
stepTitle: step.title,
sectionTitle: section.title,
})) || []
) || []
) || [];
// 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"> <div className="flex justify-between items-center">
<span className="font-medium">Rule {index + 1}</span> <span className="font-medium text-sm">Rule {index + 1}</span>
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6 text-red-500" className="h-6 w-6 text-red-500 hover-text-background hover:text-red-700"
onClick={() => { onClick={() => {
updateSchema((prev) => ({ updateSchema((prev) => ({
...prev, ...prev,
@@ -1049,22 +1013,154 @@ const AdminRegistrationBuilder = () => {
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="text-sm text-muted-foreground">
When <span className="font-mono bg-gray-100 px-1">{rule.trigger_field}</span>{' '} {/* Trigger Field Selection */}
{rule.trigger_operator}{' '} <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<span className="font-mono bg-gray-100 px-1">{String(rule.trigger_value)}</span>,{' '} <div>
{rule.action} fields:{' '} <Label className="text-xs">When this field...</Label>
<span className="font-mono bg-gray-100 px-1"> <Select
{rule.target_fields?.join(', ')} 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> </span>
</SelectItem>
))}
</SelectContent>
</Select>
</div> </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>
</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>
);
})}
{(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>
)} )}

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

@@ -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,12 +266,13 @@ const MembersDirectory = () => {
)} )}
{/* Contact Information */} {/* Contact Information */}
{(isFieldEnabled('directory_email') || isFieldEnabled('directory_phone') || isFieldEnabled('directory_address') || isFieldEnabled('directory_dob')) && (
<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" }}>
Contact Information Contact Information
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{selectedMember.directory_email && ( {isFieldEnabled('directory_email') && selectedMember.directory_email && (
<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)]">
<Mail className="h-5 w-5 text-brand-purple " /> <Mail className="h-5 w-5 text-brand-purple " />
@@ -287,7 +290,7 @@ const MembersDirectory = () => {
</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 " />
@@ -305,7 +308,7 @@ const MembersDirectory = () => {
</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 " />
@@ -319,7 +322,7 @@ const MembersDirectory = () => {
</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)]" />
@@ -334,9 +337,10 @@ const MembersDirectory = () => {
)} )}
</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

@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
const API_URL = process.env.REACT_APP_BACKEND_URL; const API_URL = process.env.REACT_APP_BACKEND_URL;
const BASENAME = process.env.REACT_APP_BASENAME || '';
export const api = axios.create({ export const api = axios.create({
baseURL: `${API_URL}/api`, baseURL: `${API_URL}/api`,
@@ -30,6 +31,35 @@ 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
// Include BASENAME to support subpath deployments
const currentPath = window.location.pathname;
const loginPath = `${BASENAME}/login?session=expired`;
if (!currentPath.includes('/login')) {
window.location.replace(loginPath);
}
}
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:', {