Compare commits
1 Commits
691dbad1b4
...
features
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0ee505339 |
@@ -48,7 +48,6 @@ 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';
|
||||||
@@ -320,8 +319,6 @@ function App() {
|
|||||||
<Route path="stripe" element={<AdminSettings />} />
|
<Route path="stripe" element={<AdminSettings />} />
|
||||||
<Route path="permissions" element={<AdminRoles />} />
|
<Route path="permissions" element={<AdminRoles />} />
|
||||||
<Route path="theme" element={<AdminTheme />} />
|
<Route path="theme" element={<AdminTheme />} />
|
||||||
<Route path="directory" element={<AdminDirectorySettings />} />
|
|
||||||
<Route path="registration" element={<AdminRegistrationBuilder />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* 404 - Catch all undefined routes */}
|
{/* 404 - Catch all undefined routes */}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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,
|
||||||
@@ -21,7 +20,6 @@ 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();
|
||||||
@@ -35,13 +33,11 @@ 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(() => {
|
||||||
@@ -124,83 +120,6 @@ 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;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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) => {
|
||||||
@@ -21,7 +20,6 @@ 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 */}
|
||||||
@@ -50,7 +48,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Partner Name */}
|
{/* Partner Name */}
|
||||||
{isFieldEnabled('directory_partner_name') && member.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" }}>
|
||||||
@@ -60,7 +58,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bio */}
|
{/* Bio */}
|
||||||
{isFieldEnabled('directory_bio') && member.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>
|
||||||
@@ -81,7 +79,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
|
|
||||||
{/* Contact Information */}
|
{/* Contact Information */}
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
{isFieldEnabled('directory_email') && member.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
|
||||||
@@ -94,7 +92,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isFieldEnabled('directory_phone') && member.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
|
||||||
@@ -107,7 +105,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isFieldEnabled('directory_address') && member.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" }}>
|
||||||
@@ -118,7 +116,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Social Media Links */}
|
{/* Social Media Links */}
|
||||||
{isFieldEnabled('social_media') && (member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
|
{(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 && (
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
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
|
||||||
*
|
*
|
||||||
@@ -25,9 +28,6 @@ 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);
|
||||||
|
|||||||
@@ -1,12 +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, BookUser } from 'lucide-react';
|
import { CreditCard, Shield, Star, Palette, FileEdit } 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: 'Theme', path: '/admin/settings/theme', icon: Palette },
|
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
|
||||||
{ label: 'Directory', path: '/admin/settings/directory', icon: BookUser },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const SettingsTabs = () => {
|
const SettingsTabs = () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -25,11 +26,13 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -73,9 +76,6 @@ 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);
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate, Link, useLocation, useSearchParams } from 'react-router-dom';
|
import { useNavigate, Link } 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,8 +13,6 @@ import { ArrowRight, ArrowLeft } from 'lucide-react';
|
|||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -22,30 +20,6 @@ const Login = () => {
|
|||||||
password: ''
|
password: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show session expiry message on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const sessionParam = searchParams.get('session');
|
|
||||||
const stateMessage = location.state?.message;
|
|
||||||
|
|
||||||
if (sessionParam === 'expired') {
|
|
||||||
toast.info('Your session has expired. Please log in again.', {
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
// Clean up URL
|
|
||||||
window.history.replaceState({}, '', '/login');
|
|
||||||
} else if (sessionParam === 'idle') {
|
|
||||||
toast.info('You were logged out due to inactivity. Please log in again.', {
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
// Clean up URL
|
|
||||||
window.history.replaceState({}, '', '/login');
|
|
||||||
} else if (stateMessage) {
|
|
||||||
toast.info(stateMessage, {
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [searchParams, location.state]);
|
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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();
|
||||||
@@ -30,7 +29,6 @@ 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: '',
|
||||||
@@ -429,121 +427,107 @@ 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)]" />
|
Member Directory Settings
|
||||||
Member Directory Settings
|
</h4>
|
||||||
</h4>
|
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
Control your visibility and information in the member directory.
|
||||||
Control your visibility and information in the member directory.
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg">
|
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="show_in_directory"
|
id="show_in_directory"
|
||||||
name="show_in_directory"
|
name="show_in_directory"
|
||||||
checked={formData.show_in_directory}
|
checked={formData.show_in_directory}
|
||||||
onChange={handleCheckboxChange}
|
onChange={handleCheckboxChange}
|
||||||
className="ui-checkbox"
|
className="ui-checkbox"
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium">
|
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium">
|
||||||
Include me in the member directory
|
Include me in the member directory
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.show_in_directory && (
|
|
||||||
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
|
|
||||||
{isFieldEnabled('directory_email') && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="directory_email">Directory Email</Label>
|
|
||||||
<Input
|
|
||||||
id="directory_email"
|
|
||||||
name="directory_email"
|
|
||||||
type="email"
|
|
||||||
value={formData.directory_email}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
|
||||||
placeholder="Optional - email to show in directory"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isFieldEnabled('directory_bio') && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="directory_bio">Bio</Label>
|
|
||||||
<Textarea
|
|
||||||
id="directory_bio"
|
|
||||||
name="directory_bio"
|
|
||||||
value={formData.directory_bio}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
|
|
||||||
placeholder="Tell other members about yourself..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isFieldEnabled('directory_address') && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="directory_address">Address</Label>
|
|
||||||
<Input
|
|
||||||
id="directory_address"
|
|
||||||
name="directory_address"
|
|
||||||
value={formData.directory_address}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
|
||||||
placeholder="Optional - address to show in directory"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isFieldEnabled('directory_phone') && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="directory_phone">Phone</Label>
|
|
||||||
<Input
|
|
||||||
id="directory_phone"
|
|
||||||
name="directory_phone"
|
|
||||||
type="tel"
|
|
||||||
value={formData.directory_phone}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
|
||||||
placeholder="Optional - phone to show in directory"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isFieldEnabled('directory_dob') && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="directory_dob">Date of Birth</Label>
|
|
||||||
<Input
|
|
||||||
id="directory_dob"
|
|
||||||
name="directory_dob"
|
|
||||||
type="date"
|
|
||||||
value={formData.directory_dob}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isFieldEnabled('directory_partner_name') && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="directory_partner_name">Partner Name</Label>
|
|
||||||
<Input
|
|
||||||
id="directory_partner_name"
|
|
||||||
name="directory_partner_name"
|
|
||||||
value={formData.directory_partner_name}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
|
||||||
placeholder="Optional - partner name to show in directory"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{formData.show_in_directory && (
|
||||||
|
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="directory_email">Directory Email</Label>
|
||||||
|
<Input
|
||||||
|
id="directory_email"
|
||||||
|
name="directory_email"
|
||||||
|
type="email"
|
||||||
|
value={formData.directory_email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
placeholder="Optional - email to show in directory"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="directory_bio">Bio</Label>
|
||||||
|
<Textarea
|
||||||
|
id="directory_bio"
|
||||||
|
name="directory_bio"
|
||||||
|
value={formData.directory_bio}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
|
||||||
|
placeholder="Tell other members about yourself..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="directory_address">Address</Label>
|
||||||
|
<Input
|
||||||
|
id="directory_address"
|
||||||
|
name="directory_address"
|
||||||
|
value={formData.directory_address}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
placeholder="Optional - address to show in directory"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="directory_phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="directory_phone"
|
||||||
|
name="directory_phone"
|
||||||
|
type="tel"
|
||||||
|
value={formData.directory_phone}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
placeholder="Optional - phone to show in directory"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="directory_dob">Date of Birth</Label>
|
||||||
|
<Input
|
||||||
|
id="directory_dob"
|
||||||
|
name="directory_dob"
|
||||||
|
type="date"
|
||||||
|
value={formData.directory_dob}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="directory_partner_name">Partner Name</Label>
|
||||||
|
<Input
|
||||||
|
id="directory_partner_name"
|
||||||
|
name="directory_partner_name"
|
||||||
|
value={formData.directory_partner_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
placeholder="Optional - partner name to show in directory"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -680,36 +664,34 @@ 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" />
|
Volunteer Interests
|
||||||
Volunteer Interests
|
</h4>
|
||||||
</h4>
|
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
Select areas where you'd like to volunteer and help our community.
|
||||||
Select areas where you'd like to volunteer and help our community.
|
</p>
|
||||||
</p>
|
<div className="grid md:grid-cols-2 gap-3">
|
||||||
<div className="grid md:grid-cols-2 gap-3">
|
{volunteerOptions.map(option => (
|
||||||
{volunteerOptions.map(option => (
|
<div key={option} className="flex items-center gap-3">
|
||||||
<div key={option} className="flex items-center gap-3">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
|
||||||
id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
|
checked={formData.volunteer_interests.includes(option)}
|
||||||
checked={formData.volunteer_interests.includes(option)}
|
onChange={() => handleVolunteerToggle(option)}
|
||||||
onChange={() => handleVolunteerToggle(option)}
|
className="ui-checkbox"
|
||||||
className="ui-checkbox"
|
/>
|
||||||
/>
|
<Label
|
||||||
<Label
|
htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
|
||||||
htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
|
className="cursor-pointer text-[var(--purple-ink)]"
|
||||||
className="cursor-pointer text-[var(--purple-ink)]"
|
>
|
||||||
>
|
{option}
|
||||||
{option}
|
</Label>
|
||||||
</Label>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -498,12 +498,13 @@ const AdminRegistrationBuilder = () => {
|
|||||||
<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" />
|
||||||
|
<p>Add Step</p>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-y-2">
|
<div className="flex">
|
||||||
{sortedSteps.map((step, index) => (
|
{sortedSteps.map((step, index) => (
|
||||||
<div
|
<div
|
||||||
key={step.id}
|
key={step.id}
|
||||||
@@ -519,8 +520,7 @@ const AdminRegistrationBuilder = () => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium">Step: </div>
|
<div className="font-medium">Step: {index + 1} </div>
|
||||||
<span className="font-medium text-sm">{step.title}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Mod Buttons */}
|
{/* Mod Buttons */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -548,10 +548,11 @@ const AdminRegistrationBuilder = () => {
|
|||||||
>
|
>
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</Button> */}
|
</Button> */}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 w-6 text-red-500 hover:text-white ml-2"
|
className="h-6 w-6 text-red-500 hover-text-background ml-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteStep(step.id);
|
handleDeleteStep(step.id);
|
||||||
@@ -568,42 +569,132 @@ const AdminRegistrationBuilder = () => {
|
|||||||
{/* Sections for selected step */}
|
{/* Sections for selected step */}
|
||||||
{currentStep && (
|
{currentStep && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center mt-6 mb-4">
|
<div className="flex justify-between flex-col items-center mt-6 mb-4">
|
||||||
<h2 className="text-lg font-semibold">Sections</h2>
|
<h2 className="text-lg font-semibold self-start">Sections</h2>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
|
<Button size="sm" className='w-full' variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
<p>Add Section</p>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 ">
|
||||||
{sortedSections.map((section) => (
|
{sortedSections.map((section) => {
|
||||||
<div
|
const sortedFields = section.fields?.sort((a, b) => a.order - b.order) || [];
|
||||||
|
|
||||||
|
return (<div
|
||||||
key={section.id}
|
key={section.id}
|
||||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedSection === section.id
|
className='p-3 rounded-lg bg-background'
|
||||||
? 'border-brand-purple bg-brand-lavender/10'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedSection(section.id);
|
setSelectedSection(section.id);
|
||||||
setSelectedField(null);
|
setSelectedField(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between ">
|
||||||
<span className="text-sm">{section.title}</span>
|
<div className='flex flex-col'>
|
||||||
|
<span className="text-xl font-semibold">{section.title}</span>
|
||||||
|
{section.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">{section.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 w-6 text-red-500 hover:text-red-700"
|
className=" text-red-500 self-start hover-text-background"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteSection(section.id);
|
handleDeleteSection(section.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<p>Delete Section</p>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="p-6 mt-4 border-brand-purple rounded-xl bg-brand-lavender/10 ">
|
||||||
))}
|
<div className="flex justify-between items-center mb-6 ">
|
||||||
|
|
||||||
|
<div>test</div>
|
||||||
|
<Button size="sm" className='' onClick={() => setAddFieldDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/* Fields */}
|
||||||
|
<div
|
||||||
|
className='mb-6 p-4 rounded-lg border-2 border-dashed bg-background border-gray-200'
|
||||||
|
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-medium mb-4">title</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">{section.description}</p>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedFields.map((field) => {
|
||||||
|
const IconComponent = FIELD_TYPE_ICONS[field.type] || Type;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedField === field.id
|
||||||
|
? 'border-brand-purple bg-brand-lavender/5 ring-2 ring-brand-purple/20'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
} ${field.is_fixed ? 'bg-gray-50' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedSection(section.id);
|
||||||
|
setSelectedField(field.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
|
<IconComponent className="h-4 w-4 text-gray-500" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
({FIELD_TYPE_LABELS[field.type] || field.type})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{field.is_fixed && (
|
||||||
|
<Lock className="h-3 w-3 text-gray-400" title="Fixed field - cannot be removed" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!field.is_fixed && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 text-red-500 hover-text-background"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteField(field.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
|
||||||
|
{sortedFields.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No fields in this section. Click "Add Field" to add one.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -611,125 +702,10 @@ const AdminRegistrationBuilder = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
<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 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sortedFields.map((field) => {
|
|
||||||
const IconComponent = FIELD_TYPE_ICONS[field.type] || Type;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={field.id}
|
|
||||||
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedField === field.id
|
|
||||||
? 'border-brand-purple bg-brand-lavender/5 ring-2 ring-brand-purple/20'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
} ${field.is_fixed ? 'bg-gray-50' : ''}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedSection(section.id);
|
|
||||||
setSelectedField(field.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
|
||||||
<IconComponent className="h-4 w-4 text-gray-500" />
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-sm">
|
|
||||||
{field.label}
|
|
||||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
|
||||||
({FIELD_TYPE_LABELS[field.type] || field.type})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{field.is_fixed && (
|
|
||||||
<Lock className="h-3 w-3 text-gray-400" title="Fixed field - cannot be removed" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!field.is_fixed && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 text-red-500 hover:text-red-700"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteField(field.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{sortedFields.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
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 */}
|
{/* Right Sidebar - Field Properties */}
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h2 className="text-lg font-semibold mb-4">Field Properties</h2>
|
<h2 className="text-lg font-semibold mb-4">Edit Options</h2>
|
||||||
|
|
||||||
{selectedFieldData ? (
|
{selectedFieldData ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1026,213 +1002,45 @@ const AdminRegistrationBuilder = () => {
|
|||||||
|
|
||||||
{/* Conditional Rules Dialog */}
|
{/* Conditional Rules Dialog */}
|
||||||
<Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}>
|
<Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Conditional Rules</DialogTitle>
|
<DialogTitle>Conditional Rules</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
|
<div className="space-y-4 py-4 max-h-96 overflow-y-auto">
|
||||||
<p className="text-sm text-muted-foreground">
|
{(schema?.conditional_rules || []).map((rule, index) => (
|
||||||
Rules allow you to show or hide fields based on other field values. For example, show a "Scholarship Reason" field only when "Request Scholarship" is checked.
|
<div key={rule.id} className="p-4 border rounded-lg space-y-3">
|
||||||
</p>
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">Rule {index + 1}</span>
|
||||||
{(schema?.conditional_rules || []).map((rule, index) => {
|
<Button
|
||||||
// Get all available fields for dropdowns
|
size="icon"
|
||||||
const allFields = schema?.steps?.flatMap(step =>
|
variant="ghost"
|
||||||
step.sections?.flatMap(section =>
|
className="h-6 w-6 text-red-500 hover-text-background"
|
||||||
section.fields?.map(field => ({
|
onClick={() => {
|
||||||
id: field.id,
|
updateSchema((prev) => ({
|
||||||
label: field.label,
|
...prev,
|
||||||
type: field.type,
|
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id),
|
||||||
stepTitle: step.title,
|
}));
|
||||||
sectionTitle: section.title,
|
}}
|
||||||
})) || []
|
>
|
||||||
) || []
|
<Trash2 className="h-4 w-4" />
|
||||||
) || [];
|
</Button>
|
||||||
|
|
||||||
// Filter trigger fields (checkbox, dropdown, radio work best)
|
|
||||||
const triggerFields = allFields.filter(f =>
|
|
||||||
['checkbox', 'dropdown', 'radio', 'text'].includes(f.type)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update a specific rule
|
|
||||||
const updateRule = (ruleId, updates) => {
|
|
||||||
updateSchema((prev) => ({
|
|
||||||
...prev,
|
|
||||||
conditional_rules: prev.conditional_rules.map((r) =>
|
|
||||||
r.id === ruleId ? { ...r, ...updates } : r
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the trigger field's type to determine value input
|
|
||||||
const triggerFieldData = allFields.find(f => f.id === rule.trigger_field);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={rule.id} className="p-4 border rounded-lg space-y-4 bg-gray-50">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="font-medium text-sm">Rule {index + 1}</span>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 text-red-500 hover:text-red-700"
|
|
||||||
onClick={() => {
|
|
||||||
updateSchema((prev) => ({
|
|
||||||
...prev,
|
|
||||||
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id),
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trigger Field Selection */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">When this field...</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.trigger_field || ''}
|
|
||||||
onValueChange={(value) => updateRule(rule.id, { trigger_field: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1">
|
|
||||||
<SelectValue placeholder="Select field" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{triggerFields.map((field) => (
|
|
||||||
<SelectItem key={field.id} value={field.id}>
|
|
||||||
{field.label}
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
|
||||||
({field.type})
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">Operator</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.trigger_operator || 'equals'}
|
|
||||||
onValueChange={(value) => updateRule(rule.id, { trigger_operator: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="equals">equals</SelectItem>
|
|
||||||
<SelectItem value="not_equals">not equals</SelectItem>
|
|
||||||
<SelectItem value="contains">contains</SelectItem>
|
|
||||||
<SelectItem value="not_empty">is not empty</SelectItem>
|
|
||||||
<SelectItem value="empty">is empty</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">Value</Label>
|
|
||||||
{triggerFieldData?.type === 'checkbox' ? (
|
|
||||||
<Select
|
|
||||||
value={String(rule.trigger_value)}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateRule(rule.id, { trigger_value: value === 'true' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true">Checked (true)</SelectItem>
|
|
||||||
<SelectItem value="false">Unchecked (false)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : ['empty', 'not_empty'].includes(rule.trigger_operator) ? (
|
|
||||||
<Input
|
|
||||||
className="mt-1"
|
|
||||||
value="(no value needed)"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
className="mt-1"
|
|
||||||
value={rule.trigger_value || ''}
|
|
||||||
onChange={(e) => updateRule(rule.id, { trigger_value: e.target.value })}
|
|
||||||
placeholder="Enter value"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Selection */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">Action</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.action || 'show'}
|
|
||||||
onValueChange={(value) => updateRule(rule.id, { action: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="show">Show fields</SelectItem>
|
|
||||||
<SelectItem value="hide">Hide fields</SelectItem>
|
|
||||||
<SelectItem value="require">Make required</SelectItem>
|
|
||||||
<SelectItem value="optional">Make optional</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">Target Fields</Label>
|
|
||||||
<div className="mt-1 border rounded-md p-2 bg-white max-h-32 overflow-y-auto">
|
|
||||||
{allFields.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">No fields available</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{allFields
|
|
||||||
.filter(f => f.id !== rule.trigger_field) // Don't show trigger field as target
|
|
||||||
.map((field) => (
|
|
||||||
<div key={field.id} className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`${rule.id}-${field.id}`}
|
|
||||||
checked={(rule.target_fields || []).includes(field.id)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
const currentTargets = rule.target_fields || [];
|
|
||||||
const newTargets = checked
|
|
||||||
? [...currentTargets, field.id]
|
|
||||||
: currentTargets.filter((id) => id !== field.id);
|
|
||||||
updateRule(rule.id, { target_fields: newTargets });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`${rule.id}-${field.id}`}
|
|
||||||
className="text-xs font-normal cursor-pointer"
|
|
||||||
>
|
|
||||||
{field.label}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rule Summary */}
|
|
||||||
{rule.trigger_field && (rule.target_fields?.length || 0) > 0 && (
|
|
||||||
<div className="text-xs bg-blue-50 border border-blue-200 rounded p-2 text-blue-800">
|
|
||||||
<strong>Summary:</strong> When "{triggerFieldData?.label || rule.trigger_field}" {rule.trigger_operator} "{String(rule.trigger_value)}", {rule.action} the following fields: {rule.target_fields?.map(id => allFields.find(f => f.id === id)?.label || id).join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="text-sm text-muted-foreground">
|
||||||
})}
|
When <span className="font-mono bg-gray-100 px-1">{rule.trigger_field}</span>{' '}
|
||||||
|
{rule.trigger_operator}{' '}
|
||||||
|
<span className="font-mono bg-gray-100 px-1">{String(rule.trigger_value)}</span>,{' '}
|
||||||
|
{rule.action} fields:{' '}
|
||||||
|
<span className="font-mono bg-gray-100 px-1">
|
||||||
|
{rule.target_fields?.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{(schema?.conditional_rules || []).length === 0 && (
|
{(schema?.conditional_rules || []).length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
No conditional rules configured yet.<br />
|
No conditional rules configured. Rules allow you to show or hide fields based on
|
||||||
Click "Add Rule" to create your first rule.
|
other field values.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1303,7 +1111,7 @@ const AdminRegistrationBuilder = () => {
|
|||||||
<Label>
|
<Label>
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && (
|
{field.required && (
|
||||||
<span className="text-red-500 ml-1">*</span>
|
<span className="text-red-500 ml-1">*</span>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input disabled placeholder={field.placeholder || field.label} />
|
<Input disabled placeholder={field.placeholder || field.label} />
|
||||||
|
|||||||
@@ -16,13 +16,11 @@ 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);
|
||||||
|
|
||||||
@@ -59,7 +57,6 @@ export default function AdminSettings() {
|
|||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setFormData({
|
setFormData({
|
||||||
publishable_key: '',
|
|
||||||
secret_key: '',
|
secret_key: '',
|
||||||
webhook_secret: ''
|
webhook_secret: ''
|
||||||
});
|
});
|
||||||
@@ -68,24 +65,17 @@ 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.publishable_key || !formData.secret_key || !formData.webhook_secret) {
|
if (!formData.secret_key || !formData.webhook_secret) {
|
||||||
toast.error('All three keys are required: Publishable Key, Secret Key, and Webhook Secret');
|
toast.error('Both Secret Key and Webhook Secret are required');
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,25 +89,15 @@ 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
|
||||||
@@ -177,31 +157,6 @@ 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>
|
||||||
@@ -223,7 +178,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 (Secret key)
|
Get this from your Stripe Dashboard → Developers → API keys
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -312,7 +267,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 key prefixes</p>
|
<p className="text-sm text-gray-600">Detected from secret key prefix</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'
|
||||||
@@ -323,20 +278,6 @@ 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>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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);
|
||||||
@@ -29,7 +28,6 @@ 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') {
|
||||||
@@ -244,7 +242,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>
|
||||||
{isFieldEnabled('directory_partner_name') && selectedMember.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>
|
||||||
@@ -254,7 +252,7 @@ const MembersDirectory = () => {
|
|||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* Bio */}
|
{/* Bio */}
|
||||||
{isFieldEnabled('directory_bio') && selectedMember.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
|
||||||
@@ -266,81 +264,79 @@ 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 " />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
|
||||||
<a
|
|
||||||
href={`mailto:${selectedMember.directory_email}`}
|
|
||||||
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
{selectedMember.directory_email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||||||
|
<a
|
||||||
|
href={`mailto:${selectedMember.directory_email}`}
|
||||||
|
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{selectedMember.directory_email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFieldEnabled('directory_phone') && selectedMember.directory_phone && (
|
{selectedMember.directory_phone && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||||||
<Phone className="h-5 w-5 text-brand-purple " />
|
<Phone className="h-5 w-5 text-brand-purple " />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Phone</p>
|
|
||||||
<a
|
|
||||||
href={`tel:${selectedMember.directory_phone}`}
|
|
||||||
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
{selectedMember.directory_phone}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Phone</p>
|
||||||
|
<a
|
||||||
|
href={`tel:${selectedMember.directory_phone}`}
|
||||||
|
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{selectedMember.directory_phone}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFieldEnabled('directory_address') && selectedMember.directory_address && (
|
{selectedMember.directory_address && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||||||
<MapPin className="h-5 w-5 text-brand-purple " />
|
<MapPin className="h-5 w-5 text-brand-purple " />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</p>
|
|
||||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{selectedMember.directory_address}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</p>
|
||||||
|
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{selectedMember.directory_address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFieldEnabled('directory_dob') && selectedMember.directory_dob && (
|
{selectedMember.directory_dob && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||||||
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Birthday</p>
|
|
||||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{formatDate(selectedMember.directory_dob)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
</div>
|
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Birthday</p>
|
||||||
|
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{formatDate(selectedMember.directory_dob)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Volunteer Interests */}
|
{/* Volunteer Interests */}
|
||||||
{isFieldEnabled('volunteer_interests') && selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
|
{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
|
||||||
@@ -359,7 +355,7 @@ const MembersDirectory = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Social Media */}
|
{/* Social Media */}
|
||||||
{isFieldEnabled('social_media') && (selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
|
{(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" }}>
|
||||||
|
|||||||
@@ -30,33 +30,6 @@ api.interceptors.response.use(
|
|||||||
async (error) => {
|
async (error) => {
|
||||||
const config = error.config;
|
const config = error.config;
|
||||||
|
|
||||||
// Handle 401 Unauthorized - session expired
|
|
||||||
if (error.response && error.response.status === 401) {
|
|
||||||
// Don't redirect if it's a login request or auth check
|
|
||||||
const isAuthRequest = config?.url?.includes('/auth/login') ||
|
|
||||||
config?.url?.includes('/auth/me') ||
|
|
||||||
config?.url?.includes('/auth/permissions');
|
|
||||||
|
|
||||||
if (!isAuthRequest) {
|
|
||||||
console.warn('[API] Session expired - redirecting to login');
|
|
||||||
|
|
||||||
// Clear auth state
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
|
|
||||||
// Dispatch custom event for components to react
|
|
||||||
window.dispatchEvent(new CustomEvent('auth:session-expired'));
|
|
||||||
|
|
||||||
// Redirect to login with session expired message
|
|
||||||
// Use replace to prevent back button issues
|
|
||||||
const currentPath = window.location.pathname;
|
|
||||||
if (!currentPath.includes('/login')) {
|
|
||||||
window.location.replace('/login?session=expired');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't retry if we've already retried or if it's a client error (4xx)
|
// Don't retry if we've already retried or if it's a client error (4xx)
|
||||||
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
|
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
|
||||||
console.error('[API] Request failed:', {
|
console.error('[API] Request failed:', {
|
||||||
|
|||||||
Reference in New Issue
Block a user