Compare commits
20 Commits
529d3d4697
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa1fa903f | ||
|
|
6720b16056 | ||
|
|
356031ad15 | ||
|
|
79a727e7ba | ||
|
|
96e4b711a8 | ||
|
|
ced8d75bcc | ||
| 08c8dd3913 | |||
|
|
f0ee505339 | ||
| 68fc34d0a5 | |||
| 82ef36b439 | |||
| b3e6cfba84 | |||
| d4acef8d90 | |||
|
|
21338f1541 | ||
|
|
da366272b4 | ||
|
|
af27190e29 | ||
|
|
235156a9ee | ||
|
|
68ee22c124 | ||
|
|
5d085153f6 | ||
|
|
01a3c38085 | ||
|
|
7152382dca |
@@ -1,3 +1,3 @@
|
|||||||
REACT_APP_BACKEND_URL=http://localhost:8000
|
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||||
REACT_APP_BASENAME=/membership
|
REACT_APP_BASENAME=/
|
||||||
PUBLIC_URL=/membership
|
PUBLIC_URL=/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#664fa3" />
|
<meta name="theme-color" content="#664fa3" />
|
||||||
<meta name="description" content="LOAF - Lesbian Organization of Atlanta Family" />
|
<meta name="description" content="LOAF - Lesbians Over Age Fifty" />
|
||||||
|
|
||||||
<!-- Google Fonts -->
|
<!-- Google Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
|||||||
20
src/App.js
20
src/App.js
@@ -47,6 +47,8 @@ import AdminGallery from './pages/admin/AdminGallery';
|
|||||||
import AdminNewsletters from './pages/admin/AdminNewsletters';
|
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 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';
|
||||||
@@ -238,6 +240,20 @@ function App() {
|
|||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/admin/registration" element={
|
||||||
|
<PrivateRoute adminOnly>
|
||||||
|
<AdminLayout>
|
||||||
|
<AdminRegistrationBuilder />
|
||||||
|
</AdminLayout>
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/admin/member-tiers" element={
|
||||||
|
<PrivateRoute adminOnly>
|
||||||
|
<AdminLayout>
|
||||||
|
<AdminMemberTiers />
|
||||||
|
</AdminLayout>
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
<Route path="/admin/plans" element={
|
<Route path="/admin/plans" element={
|
||||||
<PrivateRoute adminOnly>
|
<PrivateRoute adminOnly>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@@ -292,6 +308,7 @@ function App() {
|
|||||||
<Navigate to="/admin/settings/permissions" replace />
|
<Navigate to="/admin/settings/permissions" replace />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="/admin/settings" element={
|
<Route path="/admin/settings" element={
|
||||||
<PrivateRoute adminOnly>
|
<PrivateRoute adminOnly>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@@ -302,8 +319,9 @@ function App() {
|
|||||||
<Route index element={<Navigate to="stripe" replace />} />
|
<Route index element={<Navigate to="stripe" replace />} />
|
||||||
<Route path="stripe" element={<AdminSettings />} />
|
<Route path="stripe" element={<AdminSettings />} />
|
||||||
<Route path="permissions" element={<AdminRoles />} />
|
<Route path="permissions" element={<AdminRoles />} />
|
||||||
<Route path="member-tiers" element={<AdminMemberTiers />} />
|
|
||||||
<Route path="theme" element={<AdminTheme />} />
|
<Route path="theme" element={<AdminTheme />} />
|
||||||
|
<Route path="directory" element={<AdminDirectorySettings />} />
|
||||||
|
<Route path="registration" element={<AdminRegistrationBuilder />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* 404 - Catch all undefined routes */}
|
{/* 404 - Catch all undefined routes */}
|
||||||
|
|||||||
222
src/components/AddPaymentMethodDialog.js
Normal file
222
src/components/AddPaymentMethodDialog.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Checkbox } from './ui/checkbox';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { CreditCard, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AddPaymentMethodDialog - Dialog for adding a new payment method using Stripe Elements
|
||||||
|
*
|
||||||
|
* This dialog should be wrapped in an Elements provider with a clientSecret
|
||||||
|
*
|
||||||
|
* @param {string} saveEndpoint - Optional custom API endpoint for saving (default: '/payment-methods')
|
||||||
|
*/
|
||||||
|
const AddPaymentMethodDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
clientSecret,
|
||||||
|
saveEndpoint = '/payment-methods',
|
||||||
|
}) => {
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [setAsDefault, setSetAsDefault] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!stripe || !elements) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the CardElement
|
||||||
|
const cardElement = elements.getElement(CardElement);
|
||||||
|
|
||||||
|
if (!cardElement) {
|
||||||
|
setError('Card element not found');
|
||||||
|
toast.error('Card element not found');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the SetupIntent with the card element
|
||||||
|
const { error: stripeError, setupIntent } = await stripe.confirmCardSetup(
|
||||||
|
clientSecret,
|
||||||
|
{
|
||||||
|
payment_method: {
|
||||||
|
card: cardElement,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stripeError) {
|
||||||
|
setError(stripeError.message);
|
||||||
|
toast.error(stripeError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setupIntent.status === 'succeeded') {
|
||||||
|
// Save the payment method to our backend using the specified endpoint
|
||||||
|
await api.post(saveEndpoint, {
|
||||||
|
stripe_payment_method_id: setupIntent.payment_method,
|
||||||
|
set_as_default: setAsDefault,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Payment method added successfully');
|
||||||
|
onSuccess?.();
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
setError(`Setup failed with status: ${setupIntent.status}`);
|
||||||
|
toast.error('Failed to set up payment method');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || err.message || 'Failed to save payment method';
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
|
||||||
|
<DialogHeader className="bg-brand-purple text-white px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<DialogTitle
|
||||||
|
className="text-lg font-semibold text-white"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Add Payment Method
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription
|
||||||
|
className="text-white/80 text-sm"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Enter your card details securely
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
{/* Stripe Card Element */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
className="text-[var(--purple-ink)]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Card Information
|
||||||
|
</Label>
|
||||||
|
<div className="border border-[var(--neutral-800)] rounded-xl p-4 bg-white">
|
||||||
|
<CardElement
|
||||||
|
options={{
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#2d2a4a',
|
||||||
|
fontFamily: "'Nunito Sans', sans-serif",
|
||||||
|
'::placeholder': {
|
||||||
|
color: '#9ca3af',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invalid: {
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hidePostalCode: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Set as Default Checkbox */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="setAsDefault"
|
||||||
|
checked={setAsDefault}
|
||||||
|
onCheckedChange={setSetAsDefault}
|
||||||
|
className="border-brand-purple data-[state=checked]:bg-brand-purple"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="setAsDefault"
|
||||||
|
className="text-sm text-brand-purple cursor-pointer"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Set as default payment method for future payments
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p
|
||||||
|
className="text-sm text-red-600"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Security Note */}
|
||||||
|
<p
|
||||||
|
className="text-xs text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Your card information is securely processed by Stripe. We never store your full card number.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row gap-3 justify-end pt-4 border-t border-[var(--neutral-800)]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!stripe || loading}
|
||||||
|
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Add Card'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddPaymentMethodDialog;
|
||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
|
Star,
|
||||||
|
FileEdit
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||||
@@ -104,18 +106,31 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
path: '/admin',
|
path: '/admin',
|
||||||
disabled: false
|
disabled: false
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'Staff',
|
name: 'Staff & Admins',
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
path: '/admin/staff',
|
path: '/admin/staff',
|
||||||
disabled: false
|
disabled: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Members',
|
name: 'Member Roster',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
path: '/admin/members',
|
path: '/admin/members',
|
||||||
disabled: false
|
disabled: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Member Tiers',
|
||||||
|
icon: Star,
|
||||||
|
path: '/admin/member-tiers',
|
||||||
|
disabled: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Registration',
|
||||||
|
icon: FileEdit,
|
||||||
|
path: '/admin/registration',
|
||||||
|
disabled: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Validations',
|
name: 'Validations',
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
@@ -316,6 +331,18 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
{/* Dashboard - Standalone */}
|
{/* Dashboard - Standalone */}
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
||||||
|
|
||||||
|
{/* Onboarding Section */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 py-2 mt-6">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Onboarding
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Registration'))}
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
|
||||||
|
</div>
|
||||||
{/* MEMBERSHIP Section */}
|
{/* MEMBERSHIP Section */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="px-4 py-2 mt-6">
|
<div className="px-4 py-2 mt-6">
|
||||||
@@ -325,9 +352,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff'))}
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Roster'))}
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Members'))}
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Tiers'))}
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff & Admins'))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FINANCIALS Section */}
|
{/* FINANCIALS Section */}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="btn-outline mr-33"
|
className="btn-outline mr-33 text-white"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
1138
src/components/ComprehensiveImportWizard.js
Normal file
1138
src/components/ComprehensiveImportWizard.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import api from '../utils/api';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,6 +21,7 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
|
|||||||
* - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
|
* - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
|
||||||
* - Auto-logout on expiration
|
* - Auto-logout on expiration
|
||||||
* - "Stay Logged In" extends session
|
* - "Stay Logged In" extends session
|
||||||
|
* - Checks session validity when tab becomes visible after being hidden
|
||||||
*/
|
*/
|
||||||
const IdleSessionWarning = () => {
|
const IdleSessionWarning = () => {
|
||||||
const { user, logout, refreshUser } = useAuth();
|
const { user, logout, refreshUser } = useAuth();
|
||||||
@@ -33,11 +35,13 @@ const IdleSessionWarning = () => {
|
|||||||
const [showWarning, setShowWarning] = useState(false);
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
const [timeRemaining, setTimeRemaining] = useState(60); // seconds
|
const [timeRemaining, setTimeRemaining] = useState(60); // seconds
|
||||||
const [isExtending, setIsExtending] = useState(false);
|
const [isExtending, setIsExtending] = useState(false);
|
||||||
|
const [isCheckingSession, setIsCheckingSession] = useState(false);
|
||||||
|
|
||||||
const activityTimeoutRef = useRef(null);
|
const activityTimeoutRef = useRef(null);
|
||||||
const warningTimeoutRef = useRef(null);
|
const warningTimeoutRef = useRef(null);
|
||||||
const countdownIntervalRef = useRef(null);
|
const countdownIntervalRef = useRef(null);
|
||||||
const lastActivityRef = useRef(Date.now());
|
const lastActivityRef = useRef(Date.now());
|
||||||
|
const lastVisibilityCheckRef = useRef(Date.now());
|
||||||
|
|
||||||
// Reset activity timer
|
// Reset activity timer
|
||||||
const resetActivityTimer = useCallback(() => {
|
const resetActivityTimer = useCallback(() => {
|
||||||
@@ -120,6 +124,83 @@ const IdleSessionWarning = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if session is still valid (called when tab becomes visible)
|
||||||
|
const checkSessionValidity = useCallback(async () => {
|
||||||
|
if (!user || isCheckingSession) return;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
logger.log('[IdleSessionWarning] No token found on visibility change');
|
||||||
|
handleSessionExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCheckingSession(true);
|
||||||
|
try {
|
||||||
|
// Make a lightweight API call to verify token is still valid
|
||||||
|
await api.get('/auth/me');
|
||||||
|
logger.log('[IdleSessionWarning] Session still valid after visibility change');
|
||||||
|
|
||||||
|
// Reset the activity timer since user is back
|
||||||
|
resetActivityTimer();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[IdleSessionWarning] Session invalid on visibility change:', error);
|
||||||
|
|
||||||
|
// If 401, the interceptor will handle redirect
|
||||||
|
// For other errors, still redirect to be safe
|
||||||
|
if (error.response?.status !== 401) {
|
||||||
|
handleSessionExpired();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsCheckingSession(false);
|
||||||
|
}
|
||||||
|
}, [user, isCheckingSession, handleSessionExpired, resetActivityTimer]);
|
||||||
|
|
||||||
|
// Handle visibility change (when user returns to tab after being away)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastCheck = now - lastVisibilityCheckRef.current;
|
||||||
|
|
||||||
|
// Only check if tab was hidden for more than 1 minute
|
||||||
|
// This prevents unnecessary API calls for quick tab switches
|
||||||
|
if (timeSinceLastCheck > 60 * 1000) {
|
||||||
|
logger.log('[IdleSessionWarning] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds');
|
||||||
|
checkSessionValidity();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVisibilityCheckRef.current = now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [user, checkSessionValidity]);
|
||||||
|
|
||||||
|
// Listen for session expired event from API interceptor
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAuthSessionExpired = () => {
|
||||||
|
logger.log('[IdleSessionWarning] Received auth:session-expired event');
|
||||||
|
// Clear all timers
|
||||||
|
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
|
||||||
|
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
|
||||||
|
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
|
||||||
|
setShowWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('auth:session-expired', handleAuthSessionExpired);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('auth:session-expired', handleAuthSessionExpired);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Track user activity
|
// Track user activity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const MemberBadge = ({ memberSince, tiers }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge className={`px-3 py-2 rounded-md text-sm flex items-center gap-2 border hover:text-white ${tier.badgeClass}`}>
|
<Badge className={`px-3 py-2 rounded-md text-sm flex items-center gap-2 border hover:text-white ${tier.badgeClass}`}>
|
||||||
<Icon className="size-6" />
|
<Icon className="size-4" />
|
||||||
{tier.label}
|
{tier.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Card } from './ui/card';
|
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, User, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle, UserRound } from 'lucide-react';
|
||||||
import MemberBadge from './MemberBadge';
|
import MemberBadge from './MemberBadge';
|
||||||
|
import useDirectoryConfig from '../hooks/use-directory-config';
|
||||||
|
|
||||||
// Helper function to get initials
|
// Helper function to get initials
|
||||||
const getInitials = (firstName, lastName) => {
|
const getInitials = (firstName, lastName) => {
|
||||||
@@ -20,21 +21,24 @@ 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 */}
|
||||||
<div className='flex justify-end items-center mb-2'>
|
<div className='flex justify-end items-center mb-2'>
|
||||||
<MemberBadge memberSince={memberSince} tiers={tiers} />
|
<MemberBadge memberSince={memberSince} tiers={tiers} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-3 items-start'>
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{member.profile_photo_url ? (
|
{member.profile_photo_url ? (
|
||||||
<img
|
<img
|
||||||
src={member.profile_photo_url}
|
src={member.profile_photo_url}
|
||||||
alt={`${member.first_name} ${member.last_name}`}
|
alt={`${member.first_name} ${member.last_name}`}
|
||||||
className="w-32 h-32 rounded-full object-cover border-4 border-[var(--neutral-800)]"
|
className="size-20 rounded-full object-cover border-4 border-[var(--neutral-800)]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-32 h-32 rounded-full bg-[var(--neutral-800)] border-4 border-[var(--neutral-800)] flex items-center justify-center">
|
<div className="size-20 rounded-full bg-[var(--neutral-800)] border-4 border-[var(--neutral-800)] flex items-center justify-center">
|
||||||
<span className="text-4xl font-semibold text-brand-purple " style={{ fontFamily: "'Inter', sans-serif" }}>
|
<span className="text-4xl font-semibold text-brand-purple " style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
{getInitials(member.first_name, member.last_name)}
|
{getInitials(member.first_name, member.last_name)}
|
||||||
</span>
|
</span>
|
||||||
@@ -42,13 +46,29 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='pt-4'>
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] text-center mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] text-center mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
{member.first_name} {member.last_name}
|
{member.first_name} {member.last_name}
|
||||||
</h3>
|
</h3>
|
||||||
|
{/* Member Since */}
|
||||||
|
{memberSince && (
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<Calendar className="size-4 text-brand-lavender " />
|
||||||
|
<span className="text-sm text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Member since {new Date(memberSince).toLocaleDateString('en-US', {
|
||||||
|
// month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Partner Name */}
|
{/* Partner Name */}
|
||||||
{member.directory_partner_name && (
|
{isFieldEnabled('directory_partner_name') && member.directory_partner_name && (
|
||||||
<div className="flex items-center justify-center gap-2 mb-4">
|
<div className="flex items-center justify-center gap-2 mb-4">
|
||||||
<Heart className="h-4 w-4 text-[var(--orange-light)]" />
|
<Heart className="h-4 w-4 text-[var(--orange-light)]" />
|
||||||
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
@@ -58,28 +78,15 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bio */}
|
{/* Bio */}
|
||||||
{member.directory_bio && (
|
{isFieldEnabled('directory_bio') && member.directory_bio && (
|
||||||
<p className="text-brand-purple text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-brand-purple text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
{member.directory_bio}
|
{member.directory_bio}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Member Since */}
|
|
||||||
{memberSince && (
|
|
||||||
<div className="flex items-center justify-center gap-2 mb-4">
|
|
||||||
<Calendar className="h-4 w-4 text-brand-purple " />
|
|
||||||
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Member since {new Date(memberSince).toLocaleDateString('en-US', {
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric'
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contact Information */}
|
{/* Contact Information */}
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
{member.directory_email && (
|
{isFieldEnabled('directory_email') && member.directory_email && (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Mail className="h-4 w-4 text-brand-purple flex-shrink-0" />
|
<Mail className="h-4 w-4 text-brand-purple flex-shrink-0" />
|
||||||
<a
|
<a
|
||||||
@@ -92,31 +99,10 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{member.directory_phone && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Phone className="h-4 w-4 text-brand-purple flex-shrink-0" />
|
|
||||||
<a
|
|
||||||
href={`tel:${member.directory_phone}`}
|
|
||||||
className="text-brand-purple hover:text-[var(--purple-ink)]"
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
{member.directory_phone}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{member.directory_address && (
|
|
||||||
<div className="flex items-start gap-2 text-sm">
|
|
||||||
<MapPin className="h-4 w-4 text-brand-purple flex-shrink-0 mt-0.5" />
|
|
||||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{member.directory_address}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Social Media Links */}
|
{/* Social Media Links */}
|
||||||
{(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
|
{isFieldEnabled('social_media') && (member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
|
||||||
<div className="pt-4 border-t border-[var(--neutral-800)]">
|
<div className="pt-4 border-t border-[var(--neutral-800)]">
|
||||||
<div className="flex justify-center gap-3">
|
<div className="flex justify-center gap-3">
|
||||||
{member.social_media_facebook && (
|
{member.social_media_facebook && (
|
||||||
@@ -169,14 +155,14 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className='border-b-2 mx-4 border-[var(--neutral-800)] mb-6'></div>
|
||||||
{/* View Profile Button */}
|
{/* View Profile Button */}
|
||||||
<div className="pt-4 mt-4 border-t border-[var(--neutral-800)]">
|
<div className=" ">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onViewProfile?.(member.id)}
|
onClick={() => onViewProfile?.(member.id)}
|
||||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white rounded-full py-5"
|
className="w-full bg-brand-purple text-background hover:bg-brand-purple/90 hover:text-white rounded-full py-5"
|
||||||
>
|
>
|
||||||
<UserCircle className="h-4 w-4 mr-2" />
|
<UserRound className="size-6 mr-2 font-bold text-brand-lavender" />
|
||||||
View Full Profile
|
View Full Profile
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Calendar, Users, User, BookOpen, FileText, DollarSign, Scale } from 'lucide-react';
|
import { Calendar, Users, User, BookOpen, FileText, DollarSign, Scale } from 'lucide-react';
|
||||||
|
import { useThemeConfig } from '@/context/ThemeConfigContext';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
const MemberFooter = () => {
|
const MemberFooter = () => {
|
||||||
|
const { getLogoUrl } = useThemeConfig();
|
||||||
|
const loafLogo = getLogoUrl();
|
||||||
return (
|
return (
|
||||||
<footer className="bg-brand-dark-lavender text-white mt-auto">
|
<footer className="bg-[var(--purple-amethyst)] border-t border-[rgba(0,0,0,0.1)] text-white mt-auto">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||||
<div className="grid md:grid-cols-4 gap-8">
|
|
||||||
|
<div className="grid md:grid-cols-5 gap-8">
|
||||||
{/* Logo & About */}
|
{/* Logo & About */}
|
||||||
<div>
|
<div className="w-40 sm:w-40 md:w-48 lg:w-[180px] flex-shrink-0">
|
||||||
<h3 className="text-2xl font-bold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<img src={loafLogo} alt="LOAF Logo" className="w-full h-auto aspect-square object-contain" />
|
||||||
LOAF
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Lesbian Organization of Atlanta Family
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Member Resources */}
|
{/* Member Resources */}
|
||||||
@@ -100,18 +100,35 @@ const MemberFooter = () => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* donate button */}
|
||||||
|
<div className="flex flex-col gap-2 items-center self-start justify-center md:items-start text-left w-full sm:w-auto sm:min-w-[200px] md:min-w-[200px] ">
|
||||||
|
<div className="pb-4 w-full flex justify-center lg:justify-start">
|
||||||
|
<Link to="/donate" className="block">
|
||||||
|
<Button className="bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-full px-12 lg:px-16 py-6 text-lg sm:text-lg font-medium ">
|
||||||
|
Donate
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[var(--neutral-800)] text-sm sm:text-base font-medium text-center md:text-left w-full" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
LOAF is supported by<br />the Hollyfield Foundation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Bar */}
|
{/* Bottom Bar */}
|
||||||
<div className="border-t border-[var(--purple-lavender)]">
|
<div className="bg-gradient-to-r from-[var(--purple-deep)] to-[var(--purple-amethyst)] border-t border-[rgba(0,0,0,0.1)]">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
<div className="max-w-7xl mx-auto px-8 py-4 grid-cols-3">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-300" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-300" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</a>
|
<a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</a>
|
||||||
<a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
|
<a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
|
||||||
</div>
|
</div>
|
||||||
<p>© {new Date().getFullYear()} LOAF. All rights reserved.</p>
|
<p>© {new Date().getFullYear()} LOAF. All rights reserved.</p>
|
||||||
|
<p>Designed and Managed by <a href='https://konceptkit.com' target='_blank' className='font-bold text-white hover:underline cursor-pointer'>Konceptkit</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
151
src/components/PasswordConfirmDialog.js
Normal file
151
src/components/PasswordConfirmDialog.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Shield, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PasswordConfirmDialog - Dialog requiring admin password re-entry for sensitive actions
|
||||||
|
*/
|
||||||
|
const PasswordConfirmDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
title = 'Confirm Your Identity',
|
||||||
|
description = 'Please enter your password to proceed with this action.',
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('Password is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onConfirm(password);
|
||||||
|
setPassword('');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Invalid password');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setPassword('');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
|
||||||
|
<DialogHeader className="bg-brand-purple text-white px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<DialogTitle
|
||||||
|
className="text-lg font-semibold text-white"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription
|
||||||
|
className="text-white/80 text-sm"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-[var(--purple-ink)]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Your Password
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
className="border-[var(--neutral-800)] pr-10"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-brand-purple hover:text-[var(--purple-ink)]"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
className="text-sm text-red-500"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row gap-3 justify-end pt-4 border-t border-[var(--neutral-800)]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password.trim()}
|
||||||
|
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Confirm'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordConfirmDialog;
|
||||||
186
src/components/PaymentMethodCard.js
Normal file
186
src/components/PaymentMethodCard.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CreditCard, Trash2, Star, Banknote, Building2, FileCheck } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card brand icon mapping
|
||||||
|
*/
|
||||||
|
const getBrandIcon = (brand) => {
|
||||||
|
const brandLower = brand?.toLowerCase();
|
||||||
|
// Return text abbreviation for known brands
|
||||||
|
switch (brandLower) {
|
||||||
|
case 'visa':
|
||||||
|
return 'VISA';
|
||||||
|
case 'mastercard':
|
||||||
|
return 'MC';
|
||||||
|
case 'amex':
|
||||||
|
case 'american_express':
|
||||||
|
return 'AMEX';
|
||||||
|
case 'discover':
|
||||||
|
return 'DISC';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon for payment method type
|
||||||
|
*/
|
||||||
|
const getPaymentTypeIcon = (paymentType) => {
|
||||||
|
switch (paymentType) {
|
||||||
|
case 'cash':
|
||||||
|
return Banknote;
|
||||||
|
case 'bank_transfer':
|
||||||
|
return Building2;
|
||||||
|
case 'check':
|
||||||
|
return FileCheck;
|
||||||
|
default:
|
||||||
|
return CreditCard;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format payment type for display
|
||||||
|
*/
|
||||||
|
const formatPaymentType = (paymentType) => {
|
||||||
|
switch (paymentType) {
|
||||||
|
case 'cash':
|
||||||
|
return 'Cash';
|
||||||
|
case 'bank_transfer':
|
||||||
|
return 'Bank Transfer';
|
||||||
|
case 'check':
|
||||||
|
return 'Check';
|
||||||
|
case 'card':
|
||||||
|
return 'Card';
|
||||||
|
default:
|
||||||
|
return paymentType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PaymentMethodCard - Displays a single payment method
|
||||||
|
*/
|
||||||
|
const PaymentMethodCard = ({
|
||||||
|
method,
|
||||||
|
onSetDefault,
|
||||||
|
onDelete,
|
||||||
|
loading = false,
|
||||||
|
showActions = true,
|
||||||
|
}) => {
|
||||||
|
const PaymentIcon = getPaymentTypeIcon(method.payment_type);
|
||||||
|
const brandAbbr = method.card_brand ? getBrandIcon(method.card_brand) : null;
|
||||||
|
const isExpired = method.card_exp_year && method.card_exp_month &&
|
||||||
|
new Date(method.card_exp_year, method.card_exp_month) < new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between p-4 border rounded-xl ${
|
||||||
|
method.is_default
|
||||||
|
? 'border-brand-purple bg-[var(--lavender-500)]'
|
||||||
|
: 'border-[var(--neutral-800)] bg-white'
|
||||||
|
} ${isExpired ? 'opacity-70' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Payment Method Icon */}
|
||||||
|
<div className={`p-3 rounded-full ${
|
||||||
|
method.is_default
|
||||||
|
? 'bg-brand-purple text-white'
|
||||||
|
: 'bg-[var(--lavender-300)] text-brand-purple'
|
||||||
|
}`}>
|
||||||
|
<PaymentIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Method Details */}
|
||||||
|
<div>
|
||||||
|
{method.payment_type === 'card' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{brandAbbr && (
|
||||||
|
<span className="text-xs font-bold text-[var(--purple-ink)] bg-[var(--lavender-300)] px-2 py-0.5 rounded">
|
||||||
|
{brandAbbr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="font-medium text-[var(--purple-ink)]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
{method.card_brand ? method.card_brand.charAt(0).toUpperCase() + method.card_brand.slice(1) : 'Card'} •••• {method.card_last4 || '****'}
|
||||||
|
</span>
|
||||||
|
{method.is_default && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||||
|
<Star className="h-3 w-3 fill-current" />
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-sm ${isExpired ? 'text-red-500' : 'text-brand-purple'}`}
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{isExpired ? 'Expired' : 'Expires'} {method.card_exp_month?.toString().padStart(2, '0')}/{method.card_exp_year?.toString().slice(-2)}
|
||||||
|
{method.card_funding && (
|
||||||
|
<span className="ml-2 text-xs capitalize">({method.card_funding})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="font-medium text-[var(--purple-ink)]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
{formatPaymentType(method.payment_type)}
|
||||||
|
</span>
|
||||||
|
{method.is_default && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||||
|
<Star className="h-3 w-3 fill-current" />
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{method.manual_notes && (
|
||||||
|
<p
|
||||||
|
className="text-sm text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{method.manual_notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{showActions && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!method.is_default && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSetDefault?.(method.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="border border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg text-xs px-3"
|
||||||
|
>
|
||||||
|
Set Default
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete?.(method.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="border border-red-500 text-red-500 hover:bg-red-50 rounded-lg p-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentMethodCard;
|
||||||
309
src/components/PaymentMethodsSection.js
Normal file
309
src/components/PaymentMethodsSection.js
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Elements } from '@stripe/react-stripe-js';
|
||||||
|
import { Card } from './ui/card';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import useStripeConfig from '../hooks/use-stripe-config';
|
||||||
|
import PaymentMethodCard from './PaymentMethodCard';
|
||||||
|
import AddPaymentMethodDialog from './AddPaymentMethodDialog';
|
||||||
|
import ConfirmationDialog from './ConfirmationDialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PaymentMethodsSection - Manages user payment methods
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - List saved payment methods
|
||||||
|
* - Add new payment method via Stripe SetupIntent
|
||||||
|
* - Set default payment method
|
||||||
|
* - Delete payment methods
|
||||||
|
*/
|
||||||
|
const PaymentMethodsSection = () => {
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Get Stripe configuration from API
|
||||||
|
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const [clientSecret, setClientSecret] = useState(null);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [methodToDelete, setMethodToDelete] = useState(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch payment methods from API
|
||||||
|
*/
|
||||||
|
const fetchPaymentMethods = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await api.get('/payment-methods');
|
||||||
|
setPaymentMethods(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to load payment methods';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Failed to fetch payment methods:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPaymentMethods();
|
||||||
|
}, [fetchPaymentMethods]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create SetupIntent and open add dialog
|
||||||
|
*/
|
||||||
|
const handleAddNew = async () => {
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
const response = await api.post('/payment-methods/setup-intent');
|
||||||
|
setClientSecret(response.data.client_secret);
|
||||||
|
setAddDialogOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to initialize payment setup';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
console.error('Failed to create setup intent:', err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful payment method addition
|
||||||
|
*/
|
||||||
|
const handleAddSuccess = () => {
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
setClientSecret(null);
|
||||||
|
fetchPaymentMethods();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a payment method as default
|
||||||
|
*/
|
||||||
|
const handleSetDefault = async (methodId) => {
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
await api.put(`/payment-methods/${methodId}/default`);
|
||||||
|
toast.success('Default payment method updated');
|
||||||
|
fetchPaymentMethods();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to update default payment method';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
console.error('Failed to set default:', err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open delete confirmation dialog
|
||||||
|
*/
|
||||||
|
const handleDeleteClick = (methodId) => {
|
||||||
|
setMethodToDelete(methodId);
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm and delete payment method
|
||||||
|
*/
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!methodToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
await api.delete(`/payment-methods/${methodToDelete}`);
|
||||||
|
toast.success('Payment method removed');
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setMethodToDelete(null);
|
||||||
|
fetchPaymentMethods();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to remove payment method';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
console.error('Failed to delete payment method:', err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stripe Elements options - simplified for CardElement
|
||||||
|
const elementsOptions = {
|
||||||
|
appearance: {
|
||||||
|
theme: 'stripe',
|
||||||
|
variables: {
|
||||||
|
colorPrimary: '#6b5b95',
|
||||||
|
colorBackground: '#ffffff',
|
||||||
|
colorText: '#2d2a4a',
|
||||||
|
colorDanger: '#ef4444',
|
||||||
|
fontFamily: "'Nunito Sans', sans-serif",
|
||||||
|
borderRadius: '12px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="space-y-4 px-6 pb-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-0 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5" />
|
||||||
|
<h3
|
||||||
|
className="font-semibold"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Payment Methods
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddNew}
|
||||||
|
disabled={actionLoading}
|
||||||
|
size="sm"
|
||||||
|
className="bg-white text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg px-3 py-1"
|
||||||
|
>
|
||||||
|
{actionLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
|
||||||
|
<span
|
||||||
|
className="ml-2 text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Loading payment methods...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
|
||||||
|
<p
|
||||||
|
className="text-sm text-red-600"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchPaymentMethods}
|
||||||
|
className="ml-auto border-red-500 text-red-500 hover:bg-red-50 rounded-lg"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Methods List */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{paymentMethods.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CreditCard className="h-12 w-12 text-[var(--lavender-500)] mx-auto mb-3" />
|
||||||
|
<p
|
||||||
|
className="text-brand-purple mb-2"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
No payment methods saved
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-sm text-brand-purple/70 mb-4"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Add a card to make payments easier
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddNew}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
||||||
|
>
|
||||||
|
{actionLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Setting up...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Payment Method
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
paymentMethods.map((method) => (
|
||||||
|
<PaymentMethodCard
|
||||||
|
key={method.id}
|
||||||
|
method={method}
|
||||||
|
onSetDefault={handleSetDefault}
|
||||||
|
onDelete={handleDeleteClick}
|
||||||
|
loading={actionLoading}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Text */}
|
||||||
|
{!loading && paymentMethods.length > 0 && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-brand-purple/70 pt-2 border-t border-[var(--neutral-800)]"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Your default payment method will be used for subscription renewals and donations.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Payment Method Dialog */}
|
||||||
|
{clientSecret && stripePromise && (
|
||||||
|
<Elements stripe={stripePromise} options={elementsOptions}>
|
||||||
|
<AddPaymentMethodDialog
|
||||||
|
open={addDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setAddDialogOpen(open);
|
||||||
|
if (!open) setClientSecret(null);
|
||||||
|
}}
|
||||||
|
onSuccess={handleAddSuccess}
|
||||||
|
clientSecret={clientSecret}
|
||||||
|
/>
|
||||||
|
</Elements>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={deleteConfirmOpen}
|
||||||
|
onOpenChange={setDeleteConfirmOpen}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
title="Remove Payment Method"
|
||||||
|
description="Are you sure you want to remove this payment method? This action cannot be undone."
|
||||||
|
confirmText="Remove"
|
||||||
|
cancelText="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
loading={actionLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentMethodsSection;
|
||||||
@@ -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 } from 'lucide-react';
|
import { CreditCard, Shield, Star, Palette, FileEdit, BookUser } from 'lucide-react';
|
||||||
|
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
|
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
|
||||||
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
|
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
|
||||||
{ label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star },
|
|
||||||
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
|
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
|
||||||
|
{ label: 'Directory', path: '/admin/settings/directory', icon: BookUser },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SettingsTabs = () => {
|
const SettingsTabs = () => {
|
||||||
|
|||||||
1084
src/components/TemplateImportWizard.js
Normal file
1084
src/components/TemplateImportWizard.js
Normal file
File diff suppressed because it is too large
Load Diff
531
src/components/admin/AdminPaymentMethodsPanel.js
Normal file
531
src/components/admin/AdminPaymentMethodsPanel.js
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Elements } from '@stripe/react-stripe-js';
|
||||||
|
import { Card } from '../ui/card';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '../ui/select';
|
||||||
|
import { Textarea } from '../ui/textarea';
|
||||||
|
import { Label } from '../ui/label';
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Eye,
|
||||||
|
Banknote,
|
||||||
|
Building2,
|
||||||
|
FileCheck,
|
||||||
|
Trash2,
|
||||||
|
Star,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api from '../../utils/api';
|
||||||
|
import useStripeConfig from '../../hooks/use-stripe-config';
|
||||||
|
import ConfirmationDialog from '../ConfirmationDialog';
|
||||||
|
import PasswordConfirmDialog from '../PasswordConfirmDialog';
|
||||||
|
import AddPaymentMethodDialog from '../AddPaymentMethodDialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon for payment method type
|
||||||
|
*/
|
||||||
|
const getPaymentTypeIcon = (paymentType) => {
|
||||||
|
switch (paymentType) {
|
||||||
|
case 'cash':
|
||||||
|
return Banknote;
|
||||||
|
case 'bank_transfer':
|
||||||
|
return Building2;
|
||||||
|
case 'check':
|
||||||
|
return FileCheck;
|
||||||
|
default:
|
||||||
|
return CreditCard;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format payment type for display
|
||||||
|
*/
|
||||||
|
const formatPaymentType = (paymentType) => {
|
||||||
|
switch (paymentType) {
|
||||||
|
case 'cash':
|
||||||
|
return 'Cash';
|
||||||
|
case 'bank_transfer':
|
||||||
|
return 'Bank Transfer';
|
||||||
|
case 'check':
|
||||||
|
return 'Check';
|
||||||
|
case 'card':
|
||||||
|
return 'Card';
|
||||||
|
default:
|
||||||
|
return paymentType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminPaymentMethodsPanel - Admin panel for managing user payment methods
|
||||||
|
*/
|
||||||
|
const AdminPaymentMethodsPanel = ({ userId, userName }) => {
|
||||||
|
const [paymentMethods, setPaymentMethods] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Get Stripe configuration from API
|
||||||
|
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [addCardDialogOpen, setAddCardDialogOpen] = useState(false);
|
||||||
|
const [addManualDialogOpen, setAddManualDialogOpen] = useState(false);
|
||||||
|
const [clientSecret, setClientSecret] = useState(null);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [methodToDelete, setMethodToDelete] = useState(null);
|
||||||
|
const [revealDialogOpen, setRevealDialogOpen] = useState(false);
|
||||||
|
const [revealedData, setRevealedData] = useState(null);
|
||||||
|
|
||||||
|
// Manual payment form state
|
||||||
|
const [manualPaymentType, setManualPaymentType] = useState('cash');
|
||||||
|
const [manualNotes, setManualNotes] = useState('');
|
||||||
|
const [manualSetDefault, setManualSetDefault] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch payment methods from API
|
||||||
|
*/
|
||||||
|
const fetchPaymentMethods = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await api.get(`/admin/users/${userId}/payment-methods`);
|
||||||
|
setPaymentMethods(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to load payment methods';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Failed to fetch payment methods:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) {
|
||||||
|
fetchPaymentMethods();
|
||||||
|
}
|
||||||
|
}, [userId, fetchPaymentMethods]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create SetupIntent for adding a card
|
||||||
|
*/
|
||||||
|
const handleAddCard = async () => {
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
const response = await api.post(`/admin/users/${userId}/payment-methods/setup-intent`);
|
||||||
|
setClientSecret(response.data.client_secret);
|
||||||
|
setAddCardDialogOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to initialize payment setup';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
console.error('Failed to create setup intent:', err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful card addition
|
||||||
|
*/
|
||||||
|
const handleCardAddSuccess = () => {
|
||||||
|
setAddCardDialogOpen(false);
|
||||||
|
setClientSecret(null);
|
||||||
|
fetchPaymentMethods();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save manual payment method
|
||||||
|
*/
|
||||||
|
const handleSaveManualPayment = async () => {
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
await api.post(`/admin/users/${userId}/payment-methods/manual`, {
|
||||||
|
payment_type: manualPaymentType,
|
||||||
|
manual_notes: manualNotes || null,
|
||||||
|
set_as_default: manualSetDefault,
|
||||||
|
});
|
||||||
|
toast.success('Manual payment method recorded');
|
||||||
|
setAddManualDialogOpen(false);
|
||||||
|
setManualPaymentType('cash');
|
||||||
|
setManualNotes('');
|
||||||
|
setManualSetDefault(false);
|
||||||
|
fetchPaymentMethods();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to record payment method';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
console.error('Failed to save manual payment:', err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a payment method as default
|
||||||
|
*/
|
||||||
|
const handleSetDefault = async (methodId) => {
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
await api.put(`/admin/users/${userId}/payment-methods/${methodId}/default`);
|
||||||
|
toast.success('Default payment method updated');
|
||||||
|
fetchPaymentMethods();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to update default';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm and delete payment method
|
||||||
|
*/
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!methodToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
await api.delete(`/admin/users/${userId}/payment-methods/${methodToDelete}`);
|
||||||
|
toast.success('Payment method removed');
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setMethodToDelete(null);
|
||||||
|
fetchPaymentMethods();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to remove payment method';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal sensitive payment details with password confirmation
|
||||||
|
*/
|
||||||
|
const handleRevealDetails = async (password) => {
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
const response = await api.post(`/admin/users/${userId}/payment-methods/reveal`, {
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
setRevealedData(response.data);
|
||||||
|
setRevealDialogOpen(false);
|
||||||
|
toast.success('Sensitive details revealed');
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.response?.data?.detail || 'Failed to reveal details';
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stripe Elements options - simplified for CardElement
|
||||||
|
const elementsOptions = {
|
||||||
|
appearance: {
|
||||||
|
theme: 'stripe',
|
||||||
|
variables: {
|
||||||
|
colorPrimary: '#6b5b95',
|
||||||
|
colorBackground: '#ffffff',
|
||||||
|
colorText: '#2d2a4a',
|
||||||
|
fontFamily: "'Nunito Sans', sans-serif",
|
||||||
|
borderRadius: '12px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5 text-brand-purple" />
|
||||||
|
<h2
|
||||||
|
className="text-lg font-semibold text-[var(--purple-ink)]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Payment Methods
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealDialogOpen(true)}
|
||||||
|
disabled={actionLoading || paymentMethods.length === 0}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
Reveal Details
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddManualDialogOpen(true)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg"
|
||||||
|
>
|
||||||
|
<Banknote className="h-4 w-4 mr-1" />
|
||||||
|
Add Manual
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddCard}
|
||||||
|
disabled={actionLoading}
|
||||||
|
size="sm"
|
||||||
|
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-lg"
|
||||||
|
>
|
||||||
|
{actionLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Card
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchPaymentMethods}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Methods List */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{paymentMethods.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-center py-6 text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
No payment methods on file for this user.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
(revealedData || paymentMethods).map((method) => {
|
||||||
|
const PaymentIcon = getPaymentTypeIcon(method.payment_type);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={method.id}
|
||||||
|
className={`flex items-center justify-between p-4 border rounded-xl ${
|
||||||
|
method.is_default
|
||||||
|
? 'border-brand-purple bg-[var(--lavender-500)]'
|
||||||
|
: 'border-[var(--neutral-800)] bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-full ${
|
||||||
|
method.is_default
|
||||||
|
? 'bg-brand-purple text-white'
|
||||||
|
: 'bg-[var(--lavender-300)] text-brand-purple'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<PaymentIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{method.payment_type === 'card' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="font-medium text-[var(--purple-ink)]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
{method.card_brand
|
||||||
|
? method.card_brand.charAt(0).toUpperCase() +
|
||||||
|
method.card_brand.slice(1)
|
||||||
|
: 'Card'}{' '}
|
||||||
|
•••• {method.card_last4 || '****'}
|
||||||
|
</span>
|
||||||
|
{method.is_default && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||||
|
<Star className="h-3 w-3 fill-current" />
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-sm text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Expires {method.card_exp_month?.toString().padStart(2, '0')}/
|
||||||
|
{method.card_exp_year?.toString().slice(-2)}
|
||||||
|
{revealedData && method.stripe_payment_method_id && (
|
||||||
|
<span className="ml-2 text-xs font-mono bg-[var(--lavender-300)] px-2 py-0.5 rounded">
|
||||||
|
{method.stripe_payment_method_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="font-medium text-[var(--purple-ink)]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
{formatPaymentType(method.payment_type)}
|
||||||
|
</span>
|
||||||
|
{method.is_default && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||||
|
<Star className="h-3 w-3 fill-current" />
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{method.manual_notes && (
|
||||||
|
<p
|
||||||
|
className="text-sm text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{method.manual_notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!method.is_default && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSetDefault(method.id)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Set Default
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setMethodToDelete(method.id);
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="border-red-500 text-red-500 hover:bg-red-50 p-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Card Dialog */}
|
||||||
|
{clientSecret && stripePromise && (
|
||||||
|
<Elements stripe={stripePromise} options={elementsOptions}>
|
||||||
|
<AddPaymentMethodDialog
|
||||||
|
open={addCardDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setAddCardDialogOpen(open);
|
||||||
|
if (!open) setClientSecret(null);
|
||||||
|
}}
|
||||||
|
onSuccess={handleCardAddSuccess}
|
||||||
|
clientSecret={clientSecret}
|
||||||
|
saveEndpoint={`/admin/users/${userId}/payment-methods`}
|
||||||
|
/>
|
||||||
|
</Elements>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Manual Payment Method Dialog */}
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={addManualDialogOpen}
|
||||||
|
onOpenChange={setAddManualDialogOpen}
|
||||||
|
onConfirm={handleSaveManualPayment}
|
||||||
|
title="Record Manual Payment Method"
|
||||||
|
description={
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Payment Type</Label>
|
||||||
|
<Select value={manualPaymentType} onValueChange={setManualPaymentType}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="cash">Cash</SelectItem>
|
||||||
|
<SelectItem value="check">Check</SelectItem>
|
||||||
|
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={manualNotes}
|
||||||
|
onChange={(e) => setManualNotes(e.target.value)}
|
||||||
|
placeholder="e.g., Check #1234, received 01/15/2026"
|
||||||
|
className="min-h-[80px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
confirmText="Save"
|
||||||
|
variant="info"
|
||||||
|
loading={actionLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={deleteConfirmOpen}
|
||||||
|
onOpenChange={setDeleteConfirmOpen}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
title="Remove Payment Method"
|
||||||
|
description="Are you sure you want to remove this payment method? This action cannot be undone."
|
||||||
|
confirmText="Remove"
|
||||||
|
variant="danger"
|
||||||
|
loading={actionLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Confirm Dialog for Reveal */}
|
||||||
|
<PasswordConfirmDialog
|
||||||
|
open={revealDialogOpen}
|
||||||
|
onOpenChange={setRevealDialogOpen}
|
||||||
|
onConfirm={handleRevealDetails}
|
||||||
|
title="Reveal Sensitive Details"
|
||||||
|
description="Enter your password to view Stripe payment method IDs. This action will be logged."
|
||||||
|
loading={actionLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminPaymentMethodsPanel;
|
||||||
427
src/components/registration/DynamicFormField.js
Normal file
427
src/components/registration/DynamicFormField.js
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Label } from '../ui/label';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Textarea } from '../ui/textarea';
|
||||||
|
import { Checkbox } from '../ui/checkbox';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '../ui/select';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicFormField - Renders form fields based on schema configuration
|
||||||
|
*
|
||||||
|
* Supports field types:
|
||||||
|
* - text, email, phone, password: Input fields
|
||||||
|
* - date: Date picker input
|
||||||
|
* - textarea: Multi-line text input
|
||||||
|
* - checkbox: Single checkbox
|
||||||
|
* - radio: Radio button group
|
||||||
|
* - dropdown: Select dropdown
|
||||||
|
* - multiselect: Checkbox group for multiple selections
|
||||||
|
* - address_group: Group of address-related fields
|
||||||
|
* - file_upload: File upload input
|
||||||
|
*/
|
||||||
|
const DynamicFormField = ({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
errors = [],
|
||||||
|
formData = {},
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
options = [],
|
||||||
|
rows = 4,
|
||||||
|
validation = {},
|
||||||
|
} = field;
|
||||||
|
|
||||||
|
const hasError = errors.length > 0;
|
||||||
|
const errorMessage = errors[0];
|
||||||
|
|
||||||
|
const formatPhoneNumber = (rawValue) => {
|
||||||
|
const digits = String(rawValue || '').replace(/\D/g, '').slice(0, 10);
|
||||||
|
if (digits.length <= 3) return digits;
|
||||||
|
if (digits.length <= 6) {
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||||
|
}
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common input className
|
||||||
|
const inputClassName = `h-14 rounded-xl border-2 ${
|
||||||
|
hasError
|
||||||
|
? 'border-red-500 focus:border-red-500'
|
||||||
|
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Handle change for different field types
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { value: newValue, type: inputType, checked } = e.target;
|
||||||
|
if (inputType === 'checkbox') {
|
||||||
|
onChange(id, checked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'phone') {
|
||||||
|
onChange(id, formatPhoneNumber(newValue));
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
onChange(id, newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectChange = (newValue) => {
|
||||||
|
onChange(id, newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (checked) => {
|
||||||
|
onChange(id, checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMultiselectChange = (optionValue) => {
|
||||||
|
const currentValues = Array.isArray(value) ? value : [];
|
||||||
|
const newValues = currentValues.includes(optionValue)
|
||||||
|
? currentValues.filter((v) => v !== optionValue)
|
||||||
|
: [...currentValues, optionValue];
|
||||||
|
onChange(id, newValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render error message
|
||||||
|
const renderError = () => {
|
||||||
|
if (!hasError) return null;
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{errorMessage}</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render label
|
||||||
|
const renderLabel = () => (
|
||||||
|
<Label htmlFor={id} className={hasError ? 'text-red-500' : ''}>
|
||||||
|
{label} {required && '*'}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render based on field type
|
||||||
|
switch (type) {
|
||||||
|
case 'text':
|
||||||
|
case 'email':
|
||||||
|
case 'phone':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
type={type === 'phone' ? 'tel' : type}
|
||||||
|
required={required}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
inputMode={type === 'phone' ? 'numeric' : undefined}
|
||||||
|
maxLength={type === 'phone' ? 14 : undefined}
|
||||||
|
className={inputClassName}
|
||||||
|
data-testid={`field-${id}`}
|
||||||
|
/>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'password':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
type="password"
|
||||||
|
required={required}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
minLength={validation.minLength}
|
||||||
|
className={inputClassName}
|
||||||
|
data-testid={`field-${id}`}
|
||||||
|
/>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'date':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
type="date"
|
||||||
|
required={required}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={inputClassName}
|
||||||
|
data-testid={`field-${id}`}
|
||||||
|
/>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
required={required}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
className={`rounded-xl border-2 ${
|
||||||
|
hasError
|
||||||
|
? 'border-red-500 focus:border-red-500'
|
||||||
|
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||||
|
}`}
|
||||||
|
data-testid={`field-${id}`}
|
||||||
|
/>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'checkbox':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
checked={value || false}
|
||||||
|
onCheckedChange={handleCheckboxChange}
|
||||||
|
data-testid={`field-${id}`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
className={`text-base cursor-pointer ${hasError ? 'text-red-500' : ''}`}
|
||||||
|
>
|
||||||
|
{label} {required && '*'}
|
||||||
|
</Label>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'radio':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<RadioGroup
|
||||||
|
value={value || ''}
|
||||||
|
onValueChange={handleSelectChange}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<div key={option.value} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={option.value}
|
||||||
|
id={`${id}-${option.value}`}
|
||||||
|
data-testid={`field-${id}-${option.value}`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${id}-${option.value}`}
|
||||||
|
className="text-base cursor-pointer"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'dropdown':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<Select value={value || ''} onValueChange={handleSelectChange}>
|
||||||
|
<SelectTrigger
|
||||||
|
className={`h-14 rounded-xl border-2 ${
|
||||||
|
hasError
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||||
|
}`}
|
||||||
|
data-testid={`field-${id}`}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={placeholder || 'Select an option'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'multiselect':
|
||||||
|
const selectedValues = Array.isArray(value) ? value : [];
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{options.map((option) => (
|
||||||
|
<div key={option.value} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${id}-${option.value}`}
|
||||||
|
checked={selectedValues.includes(option.value)}
|
||||||
|
onCheckedChange={() => handleMultiselectChange(option.value)}
|
||||||
|
data-testid={`field-${id}-${option.value}`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${id}-${option.value}`}
|
||||||
|
className="text-base cursor-pointer"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'address_group':
|
||||||
|
// Address group renders multiple related fields
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{renderLabel()}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
id={`${id}_address`}
|
||||||
|
name={`${id}_address`}
|
||||||
|
placeholder="Street Address"
|
||||||
|
value={formData[`${id}_address`] || ''}
|
||||||
|
onChange={(e) => onChange(`${id}_address`, e.target.value)}
|
||||||
|
className={inputClassName}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
<Input
|
||||||
|
id={`${id}_city`}
|
||||||
|
name={`${id}_city`}
|
||||||
|
placeholder="City"
|
||||||
|
value={formData[`${id}_city`] || ''}
|
||||||
|
onChange={(e) => onChange(`${id}_city`, e.target.value)}
|
||||||
|
className={inputClassName}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id={`${id}_state`}
|
||||||
|
name={`${id}_state`}
|
||||||
|
placeholder="State"
|
||||||
|
value={formData[`${id}_state`] || ''}
|
||||||
|
onChange={(e) => onChange(`${id}_state`, e.target.value)}
|
||||||
|
className={inputClassName}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id={`${id}_zipcode`}
|
||||||
|
name={`${id}_zipcode`}
|
||||||
|
placeholder="Zipcode"
|
||||||
|
value={formData[`${id}_zipcode`] || ''}
|
||||||
|
onChange={(e) => onChange(`${id}_zipcode`, e.target.value)}
|
||||||
|
className={inputClassName}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'file_upload':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
type="file"
|
||||||
|
accept={field.allowed_types?.join(',')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
onChange(id, file);
|
||||||
|
}}
|
||||||
|
className={`h-14 rounded-xl border-2 pt-3 ${
|
||||||
|
hasError
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||||
|
}`}
|
||||||
|
data-testid={`field-${id}`}
|
||||||
|
/>
|
||||||
|
{field.max_size_mb && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Max file size: {field.max_size_mb}MB
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown field type: ${type}`);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderLabel()}
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={inputClassName}
|
||||||
|
data-testid={`field-${id}`}
|
||||||
|
/>
|
||||||
|
{renderError()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get width class based on field width configuration
|
||||||
|
*/
|
||||||
|
export const getWidthClass = (width) => {
|
||||||
|
switch (width) {
|
||||||
|
case 'half':
|
||||||
|
return 'md:col-span-1';
|
||||||
|
case 'third':
|
||||||
|
return 'md:col-span-1';
|
||||||
|
case 'two-thirds':
|
||||||
|
return 'md:col-span-2';
|
||||||
|
case 'full':
|
||||||
|
default:
|
||||||
|
return 'md:col-span-2';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get grid columns class based on field widths in a row
|
||||||
|
*/
|
||||||
|
export const getGridClass = (fields) => {
|
||||||
|
const hasThird = fields.some((f) => f.width === 'third');
|
||||||
|
if (hasThird) {
|
||||||
|
return 'grid md:grid-cols-3 gap-4';
|
||||||
|
}
|
||||||
|
return 'grid md:grid-cols-2 gap-4';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DynamicFormField;
|
||||||
482
src/components/registration/DynamicRegistrationForm.js
Normal file
482
src/components/registration/DynamicRegistrationForm.js
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import React, { useMemo, useCallback } from 'react';
|
||||||
|
import DynamicFormField, { getWidthClass } from './DynamicFormField';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicRegistrationForm - Renders the entire registration form from schema
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Renders steps and sections based on schema
|
||||||
|
* - Handles conditional field visibility
|
||||||
|
* - Supports step navigation
|
||||||
|
* - Validates fields per step
|
||||||
|
*/
|
||||||
|
const DynamicRegistrationForm = ({
|
||||||
|
schema,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
currentStep,
|
||||||
|
errors = {},
|
||||||
|
}) => {
|
||||||
|
// Get current step data
|
||||||
|
const stepData = useMemo(() => {
|
||||||
|
const steps = schema?.steps || [];
|
||||||
|
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||||
|
return sortedSteps[currentStep - 1] || null;
|
||||||
|
}, [schema, currentStep]);
|
||||||
|
|
||||||
|
// Evaluate conditional rules to determine which fields are visible
|
||||||
|
const hiddenFields = useMemo(() => {
|
||||||
|
const rules = schema?.conditional_rules || [];
|
||||||
|
const hidden = new Set();
|
||||||
|
|
||||||
|
// First pass: collect fields that have "show" rules (hidden by default)
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.action === 'show') {
|
||||||
|
rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: evaluate rules and show/hide fields
|
||||||
|
for (const rule of rules) {
|
||||||
|
const {
|
||||||
|
trigger_field,
|
||||||
|
trigger_operator = 'equals',
|
||||||
|
trigger_value,
|
||||||
|
action,
|
||||||
|
target_fields = [],
|
||||||
|
} = rule;
|
||||||
|
|
||||||
|
const fieldValue = formData[trigger_field];
|
||||||
|
let conditionMet = false;
|
||||||
|
|
||||||
|
switch (trigger_operator) {
|
||||||
|
case 'equals':
|
||||||
|
conditionMet = fieldValue === trigger_value;
|
||||||
|
break;
|
||||||
|
case 'not_equals':
|
||||||
|
conditionMet = fieldValue !== trigger_value;
|
||||||
|
break;
|
||||||
|
case 'contains':
|
||||||
|
conditionMet = Array.isArray(fieldValue)
|
||||||
|
? fieldValue.includes(trigger_value)
|
||||||
|
: String(fieldValue || '').includes(trigger_value);
|
||||||
|
break;
|
||||||
|
case 'not_empty':
|
||||||
|
conditionMet = Boolean(fieldValue);
|
||||||
|
break;
|
||||||
|
case 'empty':
|
||||||
|
conditionMet = !Boolean(fieldValue);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conditionMet = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditionMet) {
|
||||||
|
if (action === 'show') {
|
||||||
|
target_fields.forEach((fieldId) => hidden.delete(fieldId));
|
||||||
|
} else if (action === 'hide') {
|
||||||
|
target_fields.forEach((fieldId) => hidden.add(fieldId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hidden;
|
||||||
|
}, [schema, formData]);
|
||||||
|
|
||||||
|
// Handle field change
|
||||||
|
const handleFieldChange = useCallback(
|
||||||
|
(fieldId, value) => {
|
||||||
|
onFormDataChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fieldId]: value,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[onFormDataChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if a field is visible
|
||||||
|
const isFieldVisible = useCallback(
|
||||||
|
(fieldId) => {
|
||||||
|
return !hiddenFields.has(fieldId);
|
||||||
|
},
|
||||||
|
[hiddenFields]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get errors for a specific field
|
||||||
|
const getFieldErrors = useCallback(
|
||||||
|
(fieldId) => {
|
||||||
|
return errors[fieldId] || [];
|
||||||
|
},
|
||||||
|
[errors]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group fields by their width for rendering
|
||||||
|
const groupFieldsByRow = (fields) => {
|
||||||
|
const rows = [];
|
||||||
|
let currentRow = [];
|
||||||
|
let currentRowWidth = 0;
|
||||||
|
|
||||||
|
const visibleFields = fields.filter((f) => isFieldVisible(f.id));
|
||||||
|
|
||||||
|
for (const field of visibleFields) {
|
||||||
|
const width = field.width || 'full';
|
||||||
|
let widthValue = 1;
|
||||||
|
|
||||||
|
if (width === 'half') widthValue = 0.5;
|
||||||
|
else if (width === 'third') widthValue = 0.33;
|
||||||
|
else if (width === 'two-thirds') widthValue = 0.67;
|
||||||
|
|
||||||
|
if (currentRowWidth + widthValue > 1) {
|
||||||
|
if (currentRow.length > 0) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
currentRow = [field];
|
||||||
|
currentRowWidth = widthValue;
|
||||||
|
} else {
|
||||||
|
currentRow.push(field);
|
||||||
|
currentRowWidth += widthValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRow.length > 0) {
|
||||||
|
rows.push(currentRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!stepData) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No step data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Step Header */}
|
||||||
|
{stepData.description && (
|
||||||
|
<p
|
||||||
|
className="text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{stepData.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
{stepData.sections
|
||||||
|
?.sort((a, b) => a.order - b.order)
|
||||||
|
.map((section) => {
|
||||||
|
const visibleFields = section.fields?.filter((f) =>
|
||||||
|
isFieldVisible(f.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip empty sections
|
||||||
|
if (!visibleFields || visibleFields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldRows = groupFieldsByRow(
|
||||||
|
section.fields?.sort((a, b) => a.order - b.order) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={section.id} className="space-y-4">
|
||||||
|
{/* Section Title */}
|
||||||
|
{section.title && (
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-semibold text-[var(--purple-ink)]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section Description */}
|
||||||
|
{section.description && (
|
||||||
|
<p className="text-muted-foreground">{section.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fieldRows.map((row, rowIndex) => {
|
||||||
|
// Determine grid class based on field widths
|
||||||
|
const hasThird = row.some((f) => f.width === 'third');
|
||||||
|
const hasHalf = row.some((f) => f.width === 'half');
|
||||||
|
const gridCols = hasThird
|
||||||
|
? 'grid md:grid-cols-3 gap-4'
|
||||||
|
: hasHalf
|
||||||
|
? 'grid md:grid-cols-2 gap-4'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (row.length === 1 && !hasHalf && !hasThird) {
|
||||||
|
// Single full-width field
|
||||||
|
const field = row[0];
|
||||||
|
return (
|
||||||
|
<DynamicFormField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={formData[field.id]}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
errors={getFieldErrors(field.id)}
|
||||||
|
formData={formData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`row-${rowIndex}`} className={gridCols}>
|
||||||
|
{row.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className={getWidthClass(field.width)}
|
||||||
|
>
|
||||||
|
<DynamicFormField
|
||||||
|
field={field}
|
||||||
|
value={formData[field.id]}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
errors={getFieldErrors(field.id)}
|
||||||
|
formData={formData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicStepIndicator - Renders step progress indicator
|
||||||
|
*/
|
||||||
|
export const DynamicStepIndicator = ({ steps, currentStep }) => {
|
||||||
|
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{sortedSteps.map((step, index) => {
|
||||||
|
const stepNumber = index + 1;
|
||||||
|
const isActive = stepNumber === currentStep;
|
||||||
|
const isCompleted = stepNumber < currentStep;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="flex items-center flex-1">
|
||||||
|
{/* Step Circle */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand-purple text-white'
|
||||||
|
: isCompleted
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCompleted ? '✓' : stepNumber}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`mt-2 text-sm text-center hidden md:block ${
|
||||||
|
isActive ? 'text-brand-purple font-medium' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector Line */}
|
||||||
|
{index < sortedSteps.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`flex-1 h-1 mx-4 rounded ${
|
||||||
|
isCompleted
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-[var(--neutral-800)]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a single step based on schema
|
||||||
|
*/
|
||||||
|
export const validateStep = (stepData, formData, hiddenFields) => {
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
if (!stepData?.sections) return { isValid: true, errors };
|
||||||
|
|
||||||
|
for (const section of stepData.sections) {
|
||||||
|
// Check section-level validation (e.g., atLeastOne)
|
||||||
|
const sectionValidation = section.validation || {};
|
||||||
|
if (sectionValidation.atLeastOne) {
|
||||||
|
const fieldIds = (section.fields || []).map((f) => f.id);
|
||||||
|
const hasValue = fieldIds.some((id) => {
|
||||||
|
if (hiddenFields.has(id)) return true; // Skip hidden fields
|
||||||
|
const value = formData[id];
|
||||||
|
return Boolean(value);
|
||||||
|
});
|
||||||
|
if (!hasValue) {
|
||||||
|
// Add error to first field in section
|
||||||
|
const firstFieldId = fieldIds[0];
|
||||||
|
if (firstFieldId) {
|
||||||
|
errors[firstFieldId] = [
|
||||||
|
sectionValidation.message ||
|
||||||
|
`At least one field in ${section.title || 'this section'} is required`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check field-level validation
|
||||||
|
for (const field of section.fields || []) {
|
||||||
|
const { id, required, validation = {}, type, label } = field;
|
||||||
|
|
||||||
|
// Skip hidden fields
|
||||||
|
if (hiddenFields.has(id)) continue;
|
||||||
|
|
||||||
|
// Skip client-only fields for server validation
|
||||||
|
if (field.client_only && field.id !== 'confirmPassword') continue;
|
||||||
|
|
||||||
|
const value = formData[id];
|
||||||
|
|
||||||
|
// Required check
|
||||||
|
if (required) {
|
||||||
|
const isEmpty =
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === '' ||
|
||||||
|
(Array.isArray(value) && value.length === 0);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
errors[id] = [`${label || id} is required`];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip further validation if value is empty
|
||||||
|
if (!value && value !== false) continue;
|
||||||
|
|
||||||
|
// Type-specific validation
|
||||||
|
const fieldErrors = [];
|
||||||
|
|
||||||
|
if (type === 'email') {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(value)) {
|
||||||
|
fieldErrors.push('Please enter a valid email address');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'password') {
|
||||||
|
if (validation.minLength && value.length < validation.minLength) {
|
||||||
|
fieldErrors.push(
|
||||||
|
`Password must be at least ${validation.minLength} characters`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'text' || type === 'textarea') {
|
||||||
|
if (validation.minLength && value.length < validation.minLength) {
|
||||||
|
fieldErrors.push(
|
||||||
|
`${label || id} must be at least ${validation.minLength} characters`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (validation.maxLength && value.length > validation.maxLength) {
|
||||||
|
fieldErrors.push(
|
||||||
|
`${label || id} must be at most ${validation.maxLength} characters`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match field validation (for confirmPassword)
|
||||||
|
if (validation.matchField) {
|
||||||
|
if (value !== formData[validation.matchField]) {
|
||||||
|
fieldErrors.push('Passwords do not match');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldErrors.length > 0) {
|
||||||
|
errors[id] = fieldErrors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: Object.keys(errors).length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate conditional rules to get hidden fields set
|
||||||
|
*/
|
||||||
|
export const evaluateConditionalRules = (schema, formData) => {
|
||||||
|
const rules = schema?.conditional_rules || [];
|
||||||
|
const hidden = new Set();
|
||||||
|
|
||||||
|
// First pass: collect fields that have "show" rules (hidden by default)
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.action === 'show') {
|
||||||
|
rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: evaluate rules and show/hide fields
|
||||||
|
for (const rule of rules) {
|
||||||
|
const {
|
||||||
|
trigger_field,
|
||||||
|
trigger_operator = 'equals',
|
||||||
|
trigger_value,
|
||||||
|
action,
|
||||||
|
target_fields = [],
|
||||||
|
} = rule;
|
||||||
|
|
||||||
|
const fieldValue = formData[trigger_field];
|
||||||
|
let conditionMet = false;
|
||||||
|
|
||||||
|
switch (trigger_operator) {
|
||||||
|
case 'equals':
|
||||||
|
conditionMet = fieldValue === trigger_value;
|
||||||
|
break;
|
||||||
|
case 'not_equals':
|
||||||
|
conditionMet = fieldValue !== trigger_value;
|
||||||
|
break;
|
||||||
|
case 'contains':
|
||||||
|
conditionMet = Array.isArray(fieldValue)
|
||||||
|
? fieldValue.includes(trigger_value)
|
||||||
|
: String(fieldValue || '').includes(trigger_value);
|
||||||
|
break;
|
||||||
|
case 'not_empty':
|
||||||
|
conditionMet = Boolean(fieldValue);
|
||||||
|
break;
|
||||||
|
case 'empty':
|
||||||
|
conditionMet = !Boolean(fieldValue);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conditionMet = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditionMet) {
|
||||||
|
if (action === 'show') {
|
||||||
|
target_fields.forEach((fieldId) => hidden.delete(fieldId));
|
||||||
|
} else if (action === 'hide') {
|
||||||
|
target_fields.forEach((fieldId) => hidden.add(fieldId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hidden;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DynamicRegistrationForm;
|
||||||
@@ -7,7 +7,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const buttonVariants = cva("btn", {
|
const buttonVariants = cva("btn", {
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "btn-primary",
|
default: "btn-purple",
|
||||||
secondary: "btn-secondary",
|
secondary: "btn-secondary",
|
||||||
ghost: "btn-ghost",
|
ghost: "btn-ghost",
|
||||||
outline: "btn-outline",
|
outline: "btn-outline",
|
||||||
@@ -15,6 +15,7 @@ const buttonVariants = cva("btn", {
|
|||||||
accent: "btn-accent",
|
accent: "btn-accent",
|
||||||
destructive: "btn-destructive",
|
destructive: "btn-destructive",
|
||||||
link: "btn-link",
|
link: "btn-link",
|
||||||
|
green: 'btn-green'
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "btn-md",
|
default: "btn-md",
|
||||||
|
|||||||
92
src/hooks/use-directory-config.js
Normal file
92
src/hooks/use-directory-config.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default directory configuration - used as fallback if API fails
|
||||||
|
*/
|
||||||
|
const DEFAULT_DIRECTORY_CONFIG = {
|
||||||
|
fields: {
|
||||||
|
show_in_directory: { enabled: true, label: 'Show in Directory', required: false },
|
||||||
|
directory_email: { enabled: true, label: 'Directory Email', required: false },
|
||||||
|
directory_bio: { enabled: true, label: 'Bio', required: false },
|
||||||
|
directory_address: { enabled: true, label: 'Address', required: false },
|
||||||
|
directory_phone: { enabled: true, label: 'Phone', required: false },
|
||||||
|
directory_dob: { enabled: true, label: 'Birthday', required: false },
|
||||||
|
directory_partner_name: { enabled: true, label: 'Partner Name', required: false },
|
||||||
|
volunteer_interests: { enabled: true, label: 'Volunteer Interests', required: false },
|
||||||
|
social_media: { enabled: true, label: 'Social Media Links', required: false },
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch and manage directory field configuration
|
||||||
|
* @returns {Object} - { config, loading, error, isFieldEnabled, getFieldLabel, refetch }
|
||||||
|
*/
|
||||||
|
const useDirectoryConfig = () => {
|
||||||
|
const [config, setConfig] = useState(DEFAULT_DIRECTORY_CONFIG);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await api.get('/directory/config');
|
||||||
|
setConfig(response.data || DEFAULT_DIRECTORY_CONFIG);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch directory config:', err);
|
||||||
|
setError(err);
|
||||||
|
// Use default config on error
|
||||||
|
setConfig(DEFAULT_DIRECTORY_CONFIG);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a field is enabled in the config
|
||||||
|
* @param {string} fieldId - The field ID to check (e.g., 'directory_email')
|
||||||
|
* @returns {boolean} - Whether the field is enabled
|
||||||
|
*/
|
||||||
|
const isFieldEnabled = useCallback((fieldId) => {
|
||||||
|
const field = config?.fields?.[fieldId];
|
||||||
|
return field?.enabled !== false; // Default to true if not specified
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label for a field
|
||||||
|
* @param {string} fieldId - The field ID
|
||||||
|
* @param {string} defaultLabel - Default label if not in config
|
||||||
|
* @returns {string} - The field label
|
||||||
|
*/
|
||||||
|
const getFieldLabel = useCallback((fieldId, defaultLabel = '') => {
|
||||||
|
const field = config?.fields?.[fieldId];
|
||||||
|
return field?.label || defaultLabel;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a field is required
|
||||||
|
* @param {string} fieldId - The field ID
|
||||||
|
* @returns {boolean} - Whether the field is required
|
||||||
|
*/
|
||||||
|
const isFieldRequired = useCallback((fieldId) => {
|
||||||
|
const field = config?.fields?.[fieldId];
|
||||||
|
return field?.required === true;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isFieldEnabled,
|
||||||
|
getFieldLabel,
|
||||||
|
isFieldRequired,
|
||||||
|
refetch: fetchConfig
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDirectoryConfig;
|
||||||
91
src/hooks/use-stripe-config.js
Normal file
91
src/hooks/use-stripe-config.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
// Cache the stripe promise to avoid multiple loads
|
||||||
|
let stripePromiseCache = null;
|
||||||
|
let cachedPublishableKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get Stripe configuration from the backend.
|
||||||
|
*
|
||||||
|
* Returns the Stripe publishable key and a pre-initialized Stripe promise.
|
||||||
|
* The publishable key is fetched from the backend API, allowing admins
|
||||||
|
* to configure it through the admin panel instead of environment variables.
|
||||||
|
*/
|
||||||
|
const useStripeConfig = () => {
|
||||||
|
const [publishableKey, setPublishableKey] = useState(cachedPublishableKey);
|
||||||
|
const [stripePromise, setStripePromise] = useState(stripePromiseCache);
|
||||||
|
const [loading, setLoading] = useState(!cachedPublishableKey);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [environment, setEnvironment] = useState(null);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
// If we already have a cached key, use it
|
||||||
|
if (cachedPublishableKey && stripePromiseCache) {
|
||||||
|
setPublishableKey(cachedPublishableKey);
|
||||||
|
setStripePromise(stripePromiseCache);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await api.get('/config/stripe');
|
||||||
|
const { publishable_key, environment: env } = response.data;
|
||||||
|
|
||||||
|
// Cache the key and stripe promise
|
||||||
|
cachedPublishableKey = publishable_key;
|
||||||
|
stripePromiseCache = loadStripe(publishable_key);
|
||||||
|
|
||||||
|
setPublishableKey(publishable_key);
|
||||||
|
setStripePromise(stripePromiseCache);
|
||||||
|
setEnvironment(env);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useStripeConfig] Failed to fetch Stripe config:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Failed to load Stripe configuration');
|
||||||
|
|
||||||
|
// Fallback to environment variable if available
|
||||||
|
const envKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY;
|
||||||
|
if (envKey) {
|
||||||
|
console.warn('[useStripeConfig] Falling back to environment variable');
|
||||||
|
cachedPublishableKey = envKey;
|
||||||
|
stripePromiseCache = loadStripe(envKey);
|
||||||
|
setPublishableKey(envKey);
|
||||||
|
setStripePromise(stripePromiseCache);
|
||||||
|
setEnvironment(envKey.startsWith('pk_live_') ? 'live' : 'test');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
// Function to clear cache (useful after admin updates settings)
|
||||||
|
const clearCache = useCallback(() => {
|
||||||
|
cachedPublishableKey = null;
|
||||||
|
stripePromiseCache = null;
|
||||||
|
setPublishableKey(null);
|
||||||
|
setStripePromise(null);
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publishableKey,
|
||||||
|
stripePromise,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
environment,
|
||||||
|
refetch: fetchConfig,
|
||||||
|
clearCache,
|
||||||
|
isConfigured: !!publishableKey,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useStripeConfig;
|
||||||
@@ -71,7 +71,7 @@ const BecomeMember = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Membership Process Section */}
|
{/* Membership Process Section */}
|
||||||
<div className="relative bg-gray-50 py-32 bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] ">
|
<div className="relative bg-gray-50 py-32 bg-gradient-to-bl from-[var(--neutral-100)] to-[var(--neutral-800)] ">
|
||||||
{/* Decorative shooting star element */}
|
{/* Decorative shooting star element */}
|
||||||
<div className="hidden lg:block absolute left-0 top-64 w-[195px] h-[1130px] pointer-events-none opacity-50">
|
<div className="hidden lg:block absolute left-0 top-64 w-[195px] h-[1130px] pointer-events-none opacity-50">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const BoardOfDirectors = () => {
|
|||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<main className="bg-gradient-to-b from-[var(--neutral-100:)] to-[var(--neutral-800)] pt-8 sm:pt-10 md:pt-12">
|
<main className="bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)] pt-8 sm:pt-10 md:pt-12">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className=" pt-16 pb-4 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
<section className=" pt-16 pb-4 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||||
<div className="max-w-5xl mx-auto text-center px-8">
|
<div className="max-w-5xl mx-auto text-center px-8">
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||||
@@ -286,7 +286,7 @@ const Dashboard = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||||
) : events.length > 0 ? (
|
) : events.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 gap-4">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<Link to={`/events/${event.id}`} key={event.id}>
|
<Link to={`/events/${event.id}`} key={event.id}>
|
||||||
<div
|
<div
|
||||||
@@ -311,7 +311,7 @@ const Dashboard = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center gap-4 py-12">
|
||||||
<Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
<Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No upcoming events at the moment.</p>
|
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No upcoming events at the moment.</p>
|
||||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Check back later for new events!</p>
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Check back later for new events!</p>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const Donate = () => {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<main className="bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
<main className="bg-gradient-to-bl from-[var(--neutral-100)] to-[var(--neutral-800)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="py-12">
|
<section className="py-12">
|
||||||
<div className="max-w-4xl mx-auto text-center h-full">
|
<div className="max-w-4xl mx-auto text-center h-full">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const Events = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const History = () => {
|
|||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<main className="bg-gradient-to-br from-[var(--neutral-100:)] to-[var(--neutral-700)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
<main className="bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="py-12">
|
<section className="py-12">
|
||||||
<div className="max-w-3xl mx-auto flex justify-around mb-12 flex-col gap-6 items-center lg:flex-row">
|
<div className="max-w-3xl mx-auto flex justify-around mb-12 flex-col gap-6 items-center lg:flex-row">
|
||||||
|
|||||||
@@ -84,12 +84,14 @@ const Landing = () => {
|
|||||||
<div className="absolute inset-0 z-[1] bg-background/5 backdrop-blur-xs"></div>
|
<div className="absolute inset-0 z-[1] bg-background/5 backdrop-blur-xs"></div>
|
||||||
{/* Left column Loaf Image */}
|
{/* Left column Loaf Image */}
|
||||||
<div className="relative z-10 lg:py-20 py-7 flex flex-col gap-6 sm:gap-8 items-center justify-center w-full lg:w-[530px] lg:flex-shrink-0">
|
<div className="relative z-10 lg:py-20 py-7 flex flex-col gap-6 sm:gap-8 items-center justify-center w-full lg:w-[530px] lg:flex-shrink-0">
|
||||||
<div className="flex flex-col gap-6 items-center">
|
<div className="flex flex-col gap-3 items-center">
|
||||||
<img src={heroLoaf} alt="LOAF" className="w-full max-w-xs md:max-w-[370px] h-auto object-contain" />
|
<img src={heroLoaf} alt="LOAF" className="w-full max-w-xs md:max-w-[370px] h-auto object-contain" />
|
||||||
|
<div className='text-background font-medium text-[2rem] text-center mb-2'>Lesbians Over Age Fifty</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 items-center justify-center w-full max-w-[339px]">
|
<div className="flex flex-col gap-4 items-center justify-center w-full max-w-[339px]">
|
||||||
<Link to="/become-a-member" className="w-full">
|
<Link to="/become-a-member" className="w-full">
|
||||||
<Button style={{ fontFamily: "'Nunito sans', sans-serif" }} className="bg-[var(--neutral-800)] hover:bg-background text-[var(--purple-ink)] rounded-full px-6 py-6 sm:py-[32px] text-base sm:text-lg font-medium w-full transition-colors">
|
<Button style={{ fontFamily: "'Nunito sans', sans-serif" }} className="bg-[var(--neutral-800)] hover:bg-background text-[var(--purple-ink)] rounded-full px-6 py-6 sm:py-[32px] text-base sm:text-lg font-semibold w-full transition-colors">
|
||||||
Become a Member
|
Become a Member
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -107,11 +109,11 @@ const Landing = () => {
|
|||||||
{/* About Section */}
|
{/* About Section */}
|
||||||
<section id="about" className="bg-gradient-to-b pb-10 lg:pb-44 from-white to-[var(--lavender-300)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 pt-4 sm:pt-16 md:pt-20 lg:pt-30 flex flex-col">
|
<section id="about" className="bg-gradient-to-b pb-10 lg:pb-44 from-white to-[var(--lavender-300)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 pt-4 sm:pt-16 md:pt-20 lg:pt-30 flex flex-col">
|
||||||
<div className="flex flex-col items-center pt-4">
|
<div className="flex flex-col items-center pt-4">
|
||||||
<h3 className="text-[var(--purple-deep)] px-4 pb-6 md:py-8 text-4xl leading-[60px] md:text-5xl lg:text-6xl font-extrabold text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h3 className="text-[var(--purple-deep)] px-4 pb-6 md:py-8 text-4xl leading-[60px] md:text-5xl lg:text-6xl font-extrabold text-center" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
Welcome to LOAF
|
Welcome to LOAF
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[rgba(0,0,0,0.55)] text-lg lg:text-2xl text-center font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<p className="text-[rgba(0,0,0,0.55)] text-lg lg:text-2xl text-center mx-auto font-medium max-w-[940px]" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
LOAF is Houston's social networking group for lesbians who are 50 years of age and older. LOAF hosts three main activities each month, Meet and Greets, Socials, and ActiveLOAFers. TheaterLOAFers coordinate events throughout the year.
|
LOAF is Houston's social networking group for lesbians who are 50 years of age and older. LOAF hosts three main activities each month, Meet and Greets, Socials, and ActiveLOAFers. TheaterLOAFers coordinate events throughout the year.
|
||||||
</p>
|
</p>
|
||||||
<img src={shootingStar} alt="Decorative element" className="w-full h-[197px] object-contain" />
|
<img src={shootingStar} alt="Decorative element" className="w-full h-[197px] object-contain" />
|
||||||
@@ -129,7 +131,7 @@ const Landing = () => {
|
|||||||
<div className="flex flex-col-reverse md:flex-col lg:flex-row gap-8 sm:gap-10 md:gap-12 items-center justify-center w-full max-w-6xl">
|
<div className="flex flex-col-reverse md:flex-col lg:flex-row gap-8 sm:gap-10 md:gap-12 items-center justify-center w-full max-w-6xl">
|
||||||
<Link to="/register" className="w-full sm:w-auto flex items-center justify-center">
|
<Link to="/register" className="w-full sm:w-auto flex items-center justify-center">
|
||||||
<Button className="bg-[var(--neutral-800)] hover:bg-background text-[var(--purple-ink)] rounded-full
|
<Button className="bg-[var(--neutral-800)] hover:bg-background text-[var(--purple-ink)] rounded-full
|
||||||
py-8 text-xl font-normal px-12 sm:w-[392px] transition-colors ">
|
py-8 text-xl font-medium px-12 sm:w-[392px] transition-colors ">
|
||||||
Become a Member
|
Become a Member
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { Input } from '../components/ui/input';
|
import { Input } from '../components/ui/input';
|
||||||
@@ -13,13 +13,40 @@ import { ArrowRight, ArrowLeft } from 'lucide-react';
|
|||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
const basename = process.env.REACT_APP_BASENAME || '';
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: ''
|
password: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show session expiry message on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionParam = searchParams.get('session');
|
||||||
|
const stateMessage = location.state?.message;
|
||||||
|
|
||||||
|
if (sessionParam === 'expired') {
|
||||||
|
toast.info('Your session has expired. Please log in again.', {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
// Clean up URL (respect basename for subpath deployments)
|
||||||
|
window.history.replaceState({}, '', `${basename}/login`);
|
||||||
|
} else if (sessionParam === 'idle') {
|
||||||
|
toast.info('You were logged out due to inactivity. Please log in again.', {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
// Clean up URL (respect basename for subpath deployments)
|
||||||
|
window.history.replaceState({}, '', `${basename}/login`);
|
||||||
|
} else if (stateMessage) {
|
||||||
|
toast.info(stateMessage, {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [searchParams, location.state, basename]);
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
@@ -55,7 +82,7 @@ const Login = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)]">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<div className="max-w-md mx-auto px-6 py-12 ">
|
<div className="max-w-md mx-auto px-6 py-12 ">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const MissionValues = () => {
|
|||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<main className="bg-gradient-to-b from-[var(--neutral-100:)] to-[var(--neutral-800)] px-4 sm:px-6 py-8 sm:py-12 md:py-20">
|
<main className="bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)] px-4 sm:px-6 py-8 sm:py-12 md:py-20">
|
||||||
<div className="max-w-[1400px] mx-auto">
|
<div className="max-w-[1400px] mx-auto">
|
||||||
<div className="flex md:flex-row flex-col gap-10 items-stretch">
|
<div className="flex md:flex-row flex-col gap-10 items-stretch">
|
||||||
{/* Left Card - Mission (Purple Gradient) */}
|
{/* Left Card - Mission (Purple Gradient) */}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default function PrivacyPolicy() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
<main className="bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] text-[var(--purple-deep)]">
|
<main className="bg-gradient-to-bl from-[var(--neutral-100)] to-[var(--neutral-800)] text-[var(--purple-deep)]">
|
||||||
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||||
<header className="border-b pb-6">
|
<header className="border-b pb-6">
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight" style={{ fontFamily: 'Poppins' }}>
|
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight" style={{ fontFamily: 'Poppins' }}>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import Navbar from '../components/Navbar';
|
|||||||
import { User, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2, Eye, CreditCard, Handshake, ArrowLeft } from 'lucide-react';
|
import { User, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2, Eye, CreditCard, Handshake, ArrowLeft } from 'lucide-react';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
|
||||||
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
||||||
|
import 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();
|
||||||
@@ -28,6 +30,7 @@ const Profile = () => {
|
|||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [initialFormData, setInitialFormData] = useState(null);
|
const [initialFormData, setInitialFormData] = useState(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isFieldEnabled, loading: directoryConfigLoading } = useDirectoryConfig();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
@@ -265,22 +268,10 @@ const Profile = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CreditCard className="h-6 w-6 text-brand-purple" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg px-4 py-2"
|
|
||||||
>
|
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Payment Methods Section */}
|
||||||
|
<PaymentMethodsSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -438,6 +429,7 @@ const Profile = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Member Directory Settings */}
|
{/* Member Directory Settings */}
|
||||||
|
{isFieldEnabled('show_in_directory') && (
|
||||||
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
|
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
|
||||||
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
<BookUser className="h-5 w-5 text-[var(--orange-light)]" />
|
<BookUser className="h-5 w-5 text-[var(--orange-light)]" />
|
||||||
@@ -463,6 +455,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
{formData.show_in_directory && (
|
{formData.show_in_directory && (
|
||||||
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
|
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
|
||||||
|
{isFieldEnabled('directory_email') && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="directory_email">Directory Email</Label>
|
<Label htmlFor="directory_email">Directory Email</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -475,7 +468,9 @@ const Profile = () => {
|
|||||||
placeholder="Optional - email to show in directory"
|
placeholder="Optional - email to show in directory"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFieldEnabled('directory_bio') && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="directory_bio">Bio</Label>
|
<Label htmlFor="directory_bio">Bio</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -487,7 +482,9 @@ const Profile = () => {
|
|||||||
placeholder="Tell other members about yourself..."
|
placeholder="Tell other members about yourself..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFieldEnabled('directory_address') && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="directory_address">Address</Label>
|
<Label htmlFor="directory_address">Address</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -499,7 +496,9 @@ const Profile = () => {
|
|||||||
placeholder="Optional - address to show in directory"
|
placeholder="Optional - address to show in directory"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFieldEnabled('directory_phone') && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="directory_phone">Phone</Label>
|
<Label htmlFor="directory_phone">Phone</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -512,7 +511,9 @@ const Profile = () => {
|
|||||||
placeholder="Optional - phone to show in directory"
|
placeholder="Optional - phone to show in directory"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFieldEnabled('directory_dob') && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="directory_dob">Date of Birth</Label>
|
<Label htmlFor="directory_dob">Date of Birth</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -524,7 +525,9 @@ const Profile = () => {
|
|||||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFieldEnabled('directory_partner_name') && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="directory_partner_name">Partner Name</Label>
|
<Label htmlFor="directory_partner_name">Partner Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -536,9 +539,11 @@ const Profile = () => {
|
|||||||
placeholder="Optional - partner name to show in directory"
|
placeholder="Optional - partner name to show in directory"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -675,6 +680,7 @@ const Profile = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Volunteer Interests */}
|
{/* Volunteer Interests */}
|
||||||
|
{isFieldEnabled('volunteer_interests') && (
|
||||||
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
|
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
|
||||||
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
<Users className="h-5 w-5 text-brand-purple" />
|
<Users className="h-5 w-5 text-brand-purple" />
|
||||||
@@ -703,6 +709,7 @@ const Profile = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { 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';
|
||||||
@@ -6,189 +6,221 @@ import { Card } from '../components/ui/card';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import PublicNavbar from '../components/PublicNavbar';
|
import PublicNavbar from '../components/PublicNavbar';
|
||||||
import PublicFooter from '../components/PublicFooter';
|
import PublicFooter from '../components/PublicFooter';
|
||||||
import { ArrowRight, ArrowLeft } from 'lucide-react';
|
import { ArrowRight, ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
import RegistrationStepIndicator from '../components/registration/RegistrationStepIndicator';
|
import DynamicRegistrationForm, {
|
||||||
import RegistrationStep1 from '../components/registration/RegistrationStep1';
|
DynamicStepIndicator,
|
||||||
import RegistrationStep2 from '../components/registration/RegistrationStep2';
|
validateStep,
|
||||||
import RegistrationStep3 from '../components/registration/RegistrationStep3';
|
evaluateConditionalRules,
|
||||||
import RegistrationStep4 from '../components/registration/RegistrationStep4';
|
} from '../components/registration/DynamicRegistrationForm';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
// Fallback schema for when API is unavailable
|
||||||
|
const FALLBACK_SCHEMA = {
|
||||||
|
version: '1.0',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step_account',
|
||||||
|
title: 'Account Setup',
|
||||||
|
description: 'Create your account credentials.',
|
||||||
|
order: 1,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'section_credentials',
|
||||||
|
title: 'Account Credentials',
|
||||||
|
order: 1,
|
||||||
|
fields: [
|
||||||
|
{ id: 'first_name', type: 'text', label: 'First Name', required: true, is_fixed: true, mapping: 'first_name', width: 'half', order: 1 },
|
||||||
|
{ id: 'last_name', type: 'text', label: 'Last Name', required: true, is_fixed: true, mapping: 'last_name', width: 'half', order: 2 },
|
||||||
|
{ id: 'email', type: 'email', label: 'Email Address', required: true, is_fixed: true, mapping: 'email', width: 'full', order: 3 },
|
||||||
|
{ id: 'password', type: 'password', label: 'Password', required: true, is_fixed: true, mapping: 'password', validation: { minLength: 6 }, width: 'half', order: 4 },
|
||||||
|
{ id: 'confirmPassword', type: 'password', label: 'Confirm Password', required: true, is_fixed: true, client_only: true, width: 'half', order: 5, validation: { matchField: 'password' } },
|
||||||
|
{ id: 'accepts_tos', type: 'checkbox', label: 'I accept the Terms of Service and Privacy Policy', required: true, is_fixed: true, mapping: 'accepts_tos', width: 'full', order: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conditional_rules: [],
|
||||||
|
fixed_fields: ['email', 'password', 'first_name', 'last_name', 'accepts_tos'],
|
||||||
|
};
|
||||||
|
|
||||||
const Register = () => {
|
const Register = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [schemaLoading, setSchemaLoading] = useState(true);
|
||||||
|
const [schema, setSchema] = useState(null);
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({});
|
||||||
// Step 1: Personal & Partner Information
|
const [errors, setErrors] = useState({});
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
phone: '',
|
|
||||||
date_of_birth: '',
|
|
||||||
address: '',
|
|
||||||
city: '',
|
|
||||||
state: '',
|
|
||||||
zipcode: '',
|
|
||||||
lead_sources: [],
|
|
||||||
partner_first_name: '',
|
|
||||||
partner_last_name: '',
|
|
||||||
partner_is_member: false,
|
|
||||||
partner_plan_to_become_member: false,
|
|
||||||
|
|
||||||
// Step 2: Newsletter, Volunteer & Scholarship
|
// Fetch registration schema on mount
|
||||||
referred_by_member_name: '',
|
useEffect(() => {
|
||||||
newsletter_publish_name: false,
|
const fetchSchema = async () => {
|
||||||
newsletter_publish_photo: false,
|
try {
|
||||||
newsletter_publish_birthday: false,
|
const response = await api.get('/registration/schema');
|
||||||
newsletter_publish_none: false,
|
setSchema(response.data);
|
||||||
volunteer_interests: [],
|
} catch (error) {
|
||||||
scholarship_requested: false,
|
console.error('Failed to load registration schema:', error);
|
||||||
scholarship_reason: '',
|
toast.error('Failed to load registration form. Using default form.');
|
||||||
|
setSchema(FALLBACK_SCHEMA);
|
||||||
// Step 3: Directory Settings
|
} finally {
|
||||||
show_in_directory: false,
|
setSchemaLoading(false);
|
||||||
directory_email: '',
|
}
|
||||||
directory_bio: '',
|
|
||||||
directory_address: '',
|
|
||||||
directory_phone: '',
|
|
||||||
directory_dob: '',
|
|
||||||
directory_partner_name: '',
|
|
||||||
|
|
||||||
// Step 4: Account Credentials
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
|
||||||
const { name, value, type, checked } = e.target;
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: type === 'checkbox' ? checked : value
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateStep1 = () => {
|
fetchSchema();
|
||||||
const required = ['first_name', 'last_name', 'phone', 'date_of_birth',
|
}, []);
|
||||||
'address', 'city', 'state', 'zipcode'];
|
|
||||||
for (const field of required) {
|
|
||||||
if (!formData[field]?.trim()) {
|
|
||||||
toast.error('Please fill in all required fields');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (formData.lead_sources.length === 0) {
|
|
||||||
toast.error('Please select at least one option for how you heard about us');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateStep2 = () => {
|
// Get sorted steps
|
||||||
const { newsletter_publish_name, newsletter_publish_photo,
|
const sortedSteps = useMemo(() => {
|
||||||
newsletter_publish_birthday, newsletter_publish_none } = formData;
|
if (!schema?.steps) return [];
|
||||||
|
return [...schema.steps].sort((a, b) => a.order - b.order);
|
||||||
|
}, [schema]);
|
||||||
|
|
||||||
if (!newsletter_publish_name && !newsletter_publish_photo &&
|
// Get current step data
|
||||||
!newsletter_publish_birthday && !newsletter_publish_none) {
|
const currentStepData = useMemo(() => {
|
||||||
toast.error('Please select at least one newsletter publication preference');
|
return sortedSteps[currentStep - 1] || null;
|
||||||
return false;
|
}, [sortedSteps, currentStep]);
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.scholarship_requested && !formData.scholarship_reason?.trim()) {
|
// Get hidden fields based on conditional rules
|
||||||
toast.error('Please explain your scholarship request');
|
const hiddenFields = useMemo(() => {
|
||||||
return false;
|
return evaluateConditionalRules(schema, formData);
|
||||||
}
|
}, [schema, formData]);
|
||||||
|
|
||||||
return true;
|
// Validate current step
|
||||||
};
|
const validateCurrentStep = useCallback(() => {
|
||||||
|
if (!currentStepData) return { isValid: true, errors: {} };
|
||||||
const validateStep3 = () => {
|
return validateStep(currentStepData, formData, hiddenFields);
|
||||||
return true; // No required fields
|
}, [currentStepData, formData, hiddenFields]);
|
||||||
};
|
|
||||||
|
|
||||||
const validateStep4 = () => {
|
|
||||||
if (!formData.email || !formData.password || !formData.confirmPassword) {
|
|
||||||
toast.error('Please fill in all account fields');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(formData.email)) {
|
|
||||||
toast.error('Please enter a valid email address');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password.length < 6) {
|
|
||||||
toast.error('Password must be at least 6 characters');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
|
||||||
toast.error('Passwords do not match');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Handle next step
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
let isValid = false;
|
const { isValid, errors: stepErrors } = validateCurrentStep();
|
||||||
|
|
||||||
switch (currentStep) {
|
if (!isValid) {
|
||||||
case 1: isValid = validateStep1(); break;
|
setErrors(stepErrors);
|
||||||
case 2: isValid = validateStep2(); break;
|
const firstErrorField = Object.keys(stepErrors)[0];
|
||||||
case 3: isValid = validateStep3(); break;
|
if (firstErrorField) {
|
||||||
default: isValid = false;
|
toast.error(stepErrors[firstErrorField][0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValid) {
|
setErrors({});
|
||||||
setCurrentStep(prev => Math.min(prev + 1, 4));
|
setCurrentStep((prev) => Math.min(prev + 1, sortedSteps.length));
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle previous step
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
setErrors({});
|
||||||
|
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Final validation
|
// Validate final step
|
||||||
if (!validateStep4()) return;
|
const { isValid, errors: stepErrors } = validateCurrentStep();
|
||||||
|
if (!isValid) {
|
||||||
|
setErrors(stepErrors);
|
||||||
|
const firstErrorField = Object.keys(stepErrors)[0];
|
||||||
|
if (firstErrorField) {
|
||||||
|
toast.error(stepErrors[firstErrorField][0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove confirmPassword (client-side only)
|
// Prepare submission data
|
||||||
const { confirmPassword, ...dataToSubmit } = formData;
|
const submitData = { ...formData };
|
||||||
|
|
||||||
|
// Remove client-only fields
|
||||||
|
delete submitData.confirmPassword;
|
||||||
|
|
||||||
// Convert date fields to ISO format
|
// Convert date fields to ISO format
|
||||||
const submitData = {
|
if (submitData.date_of_birth) {
|
||||||
...dataToSubmit,
|
submitData.date_of_birth = new Date(submitData.date_of_birth).toISOString();
|
||||||
date_of_birth: new Date(dataToSubmit.date_of_birth).toISOString(),
|
}
|
||||||
directory_dob: dataToSubmit.directory_dob
|
if (submitData.directory_dob) {
|
||||||
? new Date(dataToSubmit.directory_dob).toISOString()
|
submitData.directory_dob = new Date(submitData.directory_dob).toISOString();
|
||||||
: null
|
}
|
||||||
};
|
|
||||||
|
// Ensure boolean fields are actually booleans
|
||||||
|
const booleanFields = [
|
||||||
|
'partner_is_member',
|
||||||
|
'partner_plan_to_become_member',
|
||||||
|
'newsletter_publish_name',
|
||||||
|
'newsletter_publish_photo',
|
||||||
|
'newsletter_publish_birthday',
|
||||||
|
'newsletter_publish_none',
|
||||||
|
'scholarship_requested',
|
||||||
|
'show_in_directory',
|
||||||
|
'accepts_tos',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of booleanFields) {
|
||||||
|
if (field in submitData) {
|
||||||
|
submitData[field] = Boolean(submitData[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure array fields are arrays
|
||||||
|
const arrayFields = ['lead_sources', 'volunteer_interests'];
|
||||||
|
for (const field of arrayFields) {
|
||||||
|
if (field in submitData && !Array.isArray(submitData[field])) {
|
||||||
|
submitData[field] = submitData[field] ? [submitData[field]] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await register(submitData);
|
await register(submitData);
|
||||||
toast.success('Please check your email for a confirmation email.');
|
toast.success('Please check your email for a confirmation email.');
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error.response?.data?.detail || 'Registration failed. Please try again.');
|
const errorMessage = error.response?.data?.detail;
|
||||||
|
if (typeof errorMessage === 'object' && errorMessage.errors) {
|
||||||
|
// Handle structured validation errors
|
||||||
|
const errorList = errorMessage.errors;
|
||||||
|
toast.error(errorList[0] || 'Registration failed');
|
||||||
|
} else {
|
||||||
|
toast.error(errorMessage || 'Registration failed. Please try again.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show loading state while fetching schema
|
||||||
|
if (schemaLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PublicNavbar />
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-12 flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-brand-purple" />
|
||||||
|
<p className="text-muted-foreground">Loading registration form...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PublicFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<Link to="/" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors">
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors"
|
||||||
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Back to Home
|
Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
@@ -196,47 +228,34 @@ const Register = () => {
|
|||||||
|
|
||||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h1
|
||||||
|
className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
Join Our Community
|
Join Our Community
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p
|
||||||
|
className="text-lg text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
Fill out the form below to start your membership journey.
|
Fill out the form below to start your membership journey.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8" data-testid="register-form">
|
<form onSubmit={handleSubmit} className="space-y-8" data-testid="register-form">
|
||||||
<RegistrationStepIndicator currentStep={currentStep} />
|
{/* Step Indicator */}
|
||||||
|
{sortedSteps.length > 1 && (
|
||||||
{currentStep === 1 && (
|
<DynamicStepIndicator steps={sortedSteps} currentStep={currentStep} />
|
||||||
<RegistrationStep1
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
handleInputChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 2 && (
|
{/* Dynamic Form Content */}
|
||||||
<RegistrationStep2
|
<DynamicRegistrationForm
|
||||||
|
schema={schema}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
setFormData={setFormData}
|
onFormDataChange={setFormData}
|
||||||
handleInputChange={handleInputChange}
|
currentStep={currentStep}
|
||||||
|
errors={errors}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 3 && (
|
|
||||||
<RegistrationStep3
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
handleInputChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 4 && (
|
|
||||||
<RegistrationStep4
|
|
||||||
formData={formData}
|
|
||||||
handleInputChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
<div className="flex justify-between items-center pt-6">
|
<div className="flex justify-between items-center pt-6">
|
||||||
@@ -254,7 +273,7 @@ const Register = () => {
|
|||||||
<div></div>
|
<div></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep < 4 ? (
|
{currentStep < sortedSteps.length ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
@@ -270,13 +289,25 @@ const Register = () => {
|
|||||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
|
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
data-testid="submit-register-button"
|
data-testid="submit-register-button"
|
||||||
>
|
>
|
||||||
{loading ? 'Creating Account...' : 'Create Account'}
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
Creating Account...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Create Account
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-brand-purple mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p
|
||||||
|
className="text-center text-brand-purple mt-4"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
|
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
|
||||||
Login here
|
Login here
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function TermsOfService() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
<main className="bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] text-[var(--purple-deep)]">
|
<main className="bg-gradient-to-bl from-[var(--neutral-100)] to-[var(--neutral-800)] text-[var(--purple-deep)]">
|
||||||
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<header className="border-b pb-6">
|
<header className="border-b pb-6">
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const AdminDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Link to={'/'} className=''>
|
<Link to={'/'} className=''>
|
||||||
<Button
|
<Button
|
||||||
className="btn-lavender mb-8 md:mb-0 "
|
className="btn-lavender mb-8 md:mb-0 mr-4 "
|
||||||
>
|
>
|
||||||
<Globe />
|
<Globe />
|
||||||
View Public Site
|
View Public Site
|
||||||
|
|||||||
241
src/pages/admin/AdminDirectorySettings.js
Normal file
241
src/pages/admin/AdminDirectorySettings.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import api from '../../utils/api';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Switch } from '../../components/ui/switch';
|
||||||
|
import { Label } from '../../components/ui/label';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { BookUser, Save, RotateCcw, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
} from '../../components/ui/alert';
|
||||||
|
|
||||||
|
const AdminDirectorySettings = () => {
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [initialConfig, setInitialConfig] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialConfig && config) {
|
||||||
|
const changed = JSON.stringify(config) !== JSON.stringify(initialConfig);
|
||||||
|
setHasChanges(changed);
|
||||||
|
}
|
||||||
|
}, [config, initialConfig]);
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.get('/admin/directory/config');
|
||||||
|
setConfig(response.data.config);
|
||||||
|
setInitialConfig(response.data.config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch directory config:', error);
|
||||||
|
toast.error('Failed to load directory configuration');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleField = (fieldId) => {
|
||||||
|
// Don't allow disabling show_in_directory - it's required
|
||||||
|
if (fieldId === 'show_in_directory') {
|
||||||
|
toast.error('The "Show in Directory" field cannot be disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
fields: {
|
||||||
|
...prev.fields,
|
||||||
|
[fieldId]: {
|
||||||
|
...prev.fields[fieldId],
|
||||||
|
enabled: !prev.fields[fieldId].enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await api.put('/admin/directory/config', { config });
|
||||||
|
setInitialConfig(config);
|
||||||
|
setHasChanges(false);
|
||||||
|
toast.success('Directory configuration saved successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save directory config:', error);
|
||||||
|
toast.error('Failed to save directory configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (!window.confirm('Are you sure you want to reset to default settings? This will enable all directory fields.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const response = await api.post('/admin/directory/config/reset');
|
||||||
|
setConfig(response.data.config);
|
||||||
|
setInitialConfig(response.data.config);
|
||||||
|
setHasChanges(false);
|
||||||
|
toast.success('Directory configuration reset to defaults');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset directory config:', error);
|
||||||
|
toast.error('Failed to reset directory configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setConfig(initialConfig);
|
||||||
|
setHasChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Field descriptions for better UX
|
||||||
|
const fieldDescriptions = {
|
||||||
|
show_in_directory: 'Master toggle for members to opt-in to the directory (always enabled)',
|
||||||
|
directory_email: 'Email address visible to other members in the directory',
|
||||||
|
directory_bio: 'Short biography shown in directory profile and member cards',
|
||||||
|
directory_address: 'Physical address visible to other members',
|
||||||
|
directory_phone: 'Phone number visible to other members',
|
||||||
|
directory_dob: 'Birthday shown in directory profiles',
|
||||||
|
directory_partner_name: 'Partner name displayed in directory',
|
||||||
|
volunteer_interests: 'Volunteer interest badges shown in profiles',
|
||||||
|
social_media: 'Social media links (Facebook, Instagram, Twitter, LinkedIn)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Field icons for better UX
|
||||||
|
const fieldLabels = {
|
||||||
|
show_in_directory: 'Show in Directory Toggle',
|
||||||
|
directory_email: 'Directory Email',
|
||||||
|
directory_bio: 'Bio / About',
|
||||||
|
directory_address: 'Address',
|
||||||
|
directory_phone: 'Phone Number',
|
||||||
|
directory_dob: 'Birthday',
|
||||||
|
directory_partner_name: 'Partner Name',
|
||||||
|
volunteer_interests: 'Volunteer Interests',
|
||||||
|
social_media: 'Social Media Links',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-brand-purple" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<BookUser className="h-6 w-6 text-brand-purple" />
|
||||||
|
Directory Field Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Configure which fields are available in member profiles and the directory
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Alert */}
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
These settings control which fields appear in the <strong>Profile page</strong> and <strong>Member Directory</strong>.
|
||||||
|
Disabling a field will hide it from both locations. Existing data will be preserved but not displayed.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Fields Configuration */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{config && Object.entries(config.fields).map(([fieldId, fieldData]) => (
|
||||||
|
<div
|
||||||
|
key={fieldId}
|
||||||
|
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||||
|
fieldData.enabled ? 'bg-background border-border' : 'bg-muted/50 border-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-base font-medium text-foreground">
|
||||||
|
{fieldLabels[fieldId] || fieldData.label || fieldId}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{fieldDescriptions[fieldId] || fieldData.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={fieldData.enabled}
|
||||||
|
onCheckedChange={() => handleToggleField(fieldId)}
|
||||||
|
disabled={fieldId === 'show_in_directory'}
|
||||||
|
className="data-[state=checked]:bg-brand-purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{hasChanges && (
|
||||||
|
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-orange-500"></span>
|
||||||
|
Unsaved changes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
className="bg-brand-purple hover:bg-brand-purple/90 text-white"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminDirectorySettings;
|
||||||
@@ -298,7 +298,7 @@ const AdminDonations = () => {
|
|||||||
placeholder="Search by donor name or email..."
|
placeholder="Search by donor name or email..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 rounded-full border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{hasPermission('donations.export') && (
|
{hasPermission('donations.export') && (
|
||||||
@@ -336,7 +336,7 @@ const AdminDonations = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="rounded-full border-2 border-[var(--neutral-800)]">
|
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||||
<SelectValue placeholder="All Types" />
|
<SelectValue placeholder="All Types" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -349,7 +349,7 @@ const AdminDonations = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="rounded-full border-2 border-[var(--neutral-800)]">
|
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||||
<SelectValue placeholder="All Statuses" />
|
<SelectValue placeholder="All Statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -366,7 +366,7 @@ const AdminDonations = () => {
|
|||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
className="rounded-xl border-2 border-[var(--neutral-800)]"
|
||||||
placeholder="Start Date"
|
placeholder="Start Date"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,7 +376,7 @@ const AdminDonations = () => {
|
|||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
className="rounded-xl border-2 border-[var(--neutral-800)]"
|
||||||
placeholder="End Date"
|
placeholder="End Date"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,9 +150,15 @@ const AdminMemberTiers = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header and Actions */}
|
{/* Header and Actions */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Members Tiers
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Configure tier names, time ranges, and badges displayed in the members directory.
|
Configure tier names, time ranges, and badges displayed in the members directory.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Button variant="outline" onClick={handleDiscardChanges}>
|
<Button variant="outline" onClick={handleDiscardChanges}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
@@ -6,19 +6,24 @@ import { Card } from '../../components/ui/card';
|
|||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Input } from '../../components/ui/input';
|
import { Input } from '../../components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||||
|
import { Checkbox } from '../../components/ui/checkbox';
|
||||||
|
import { Badge } from '../../components/ui/badge';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
} from '../../components/ui/dropdown-menu';
|
} from '../../components/ui/dropdown-menu';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus } from 'lucide-react';
|
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus, KeyRound, Loader2, X } from 'lucide-react';
|
||||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||||
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
||||||
import InviteMemberDialog from '../../components/InviteMemberDialog';
|
import InviteMemberDialog from '../../components/InviteMemberDialog';
|
||||||
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
||||||
|
import ComprehensiveImportWizard from '../../components/ComprehensiveImportWizard';
|
||||||
|
import TemplateImportWizard from '../../components/TemplateImportWizard';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import { StatCard } from '@/components/StatCard';
|
import { StatCard } from '@/components/StatCard';
|
||||||
import { useMembers } from '../../hooks/use-users';
|
import { useMembers } from '../../hooks/use-users';
|
||||||
@@ -45,8 +50,158 @@ const AdminMembers = () => {
|
|||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [comprehensiveImportOpen, setComprehensiveImportOpen] = useState(false);
|
||||||
|
const [templateImportOpen, setTemplateImportOpen] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
// Bulk selection state
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState(new Set());
|
||||||
|
const [bulkActionLoading, setBulkActionLoading] = useState(false);
|
||||||
|
const [bulkPasswordResetOpen, setBulkPasswordResetOpen] = useState(false);
|
||||||
|
|
||||||
|
// Import job filter state
|
||||||
|
const [importJobs, setImportJobs] = useState([]);
|
||||||
|
const [selectedImportJob, setSelectedImportJob] = useState(null);
|
||||||
|
const [importJobLoading, setImportJobLoading] = useState(false);
|
||||||
|
|
||||||
|
// Fetch import jobs on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchImportJobs = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/admin/users/import-jobs');
|
||||||
|
// Filter to only show completed/partial jobs that have users
|
||||||
|
const jobsWithUsers = response.data.filter(
|
||||||
|
(job) => job.successful_rows > 0 && ['completed', 'partial'].includes(job.status)
|
||||||
|
);
|
||||||
|
setImportJobs(jobsWithUsers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch import jobs:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasPermission('users.import')) {
|
||||||
|
fetchImportJobs();
|
||||||
|
}
|
||||||
|
}, [hasPermission]);
|
||||||
|
|
||||||
|
// Select all users from an import job
|
||||||
|
const selectUsersFromImportJob = useCallback(async (jobId) => {
|
||||||
|
if (!jobId) {
|
||||||
|
setSelectedImportJob(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportJobLoading(true);
|
||||||
|
setSelectedImportJob(jobId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get import job details to get the user IDs
|
||||||
|
const response = await api.get(`/admin/users/import-jobs/${jobId}`);
|
||||||
|
const importedUserIds = response.data.imported_user_ids || [];
|
||||||
|
|
||||||
|
if (importedUserIds.length === 0) {
|
||||||
|
toast.info('No users found in this import job');
|
||||||
|
setSelectedImportJob(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only select users that are currently visible in filteredUsers
|
||||||
|
const visibleImportedUsers = filteredUsers.filter((user) =>
|
||||||
|
importedUserIds.includes(user.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleImportedUsers.length === 0) {
|
||||||
|
// If no visible users match, select from all users
|
||||||
|
const allImportedUsers = users.filter((user) =>
|
||||||
|
importedUserIds.includes(user.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allImportedUsers.length > 0) {
|
||||||
|
setSelectedUsers(new Set(allImportedUsers.map((u) => u.id)));
|
||||||
|
toast.success(
|
||||||
|
`Selected ${allImportedUsers.length} users from import job (some may be hidden by current filters)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.info('No users from this import job found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedUsers(new Set(visibleImportedUsers.map((u) => u.id)));
|
||||||
|
toast.success(`Selected ${visibleImportedUsers.length} users from import job`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to load import job';
|
||||||
|
toast.error(message);
|
||||||
|
setSelectedImportJob(null);
|
||||||
|
} finally {
|
||||||
|
setImportJobLoading(false);
|
||||||
|
}
|
||||||
|
}, [filteredUsers, users]);
|
||||||
|
|
||||||
|
// Check if all visible users are selected
|
||||||
|
const allSelected = useMemo(() => {
|
||||||
|
if (!filteredUsers || filteredUsers.length === 0) return false;
|
||||||
|
return filteredUsers.every((user) => selectedUsers.has(user.id));
|
||||||
|
}, [filteredUsers, selectedUsers]);
|
||||||
|
|
||||||
|
// Toggle single user selection
|
||||||
|
const toggleUserSelection = (userId) => {
|
||||||
|
setSelectedUsers((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(userId)) {
|
||||||
|
newSet.delete(userId);
|
||||||
|
} else {
|
||||||
|
newSet.add(userId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle all visible users
|
||||||
|
const toggleAllUsers = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedUsers(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedUsers(new Set(filteredUsers.map((user) => user.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedUsers(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle bulk password reset
|
||||||
|
const handleBulkPasswordReset = async () => {
|
||||||
|
if (selectedUsers.size === 0) {
|
||||||
|
toast.error('No users selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBulkActionLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post('/admin/users/bulk-password-reset', {
|
||||||
|
user_ids: Array.from(selectedUsers),
|
||||||
|
send_email: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Password reset emails sent to ${response.data.successful} users`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.failed > 0) {
|
||||||
|
toast.warning(`${response.data.failed} emails failed to send`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBulkPasswordResetOpen(false);
|
||||||
|
clearSelection();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.detail || 'Failed to send password reset emails';
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setBulkActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleActivatePayment = (user) => {
|
const handleActivatePayment = (user) => {
|
||||||
setSelectedUserForPayment(user);
|
setSelectedUserForPayment(user);
|
||||||
setPaymentDialogOpen(true);
|
setPaymentDialogOpen(true);
|
||||||
@@ -199,52 +354,64 @@ const AdminMembers = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 flex-wrap ">
|
<div className="flex gap-3 flex-wrap ">
|
||||||
{hasPermission('users.export') && (
|
|
||||||
|
{hasPermission('users.import') && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button className="">
|
||||||
className="btn-util-purple "
|
<Upload className="h-5 w-5 mr-2" />
|
||||||
disabled={exporting}
|
Import
|
||||||
>
|
|
||||||
{exporting ? (
|
|
||||||
<>
|
|
||||||
<Download className="h-5 w-5 mr-2 animate-bounce" />
|
|
||||||
Exporting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FileDown className="h-5 w-5 mr-2" />
|
|
||||||
Export
|
|
||||||
<ChevronDown className="h-4 w-4 ml-2" />
|
<ChevronDown className="h-4 w-4 ml-2" />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="rounded-xl">
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
<DropdownMenuItem onClick={() => handleExport('all')} className="cursor-pointer">
|
<DropdownMenuItem
|
||||||
Export All Members
|
onClick={() => setTemplateImportOpen(true)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileDown className="h-4 w-4 mr-2" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Template Import (Recommended)</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Download templates, fill your data, upload
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleExport('current')} className="cursor-pointer">
|
<DropdownMenuSeparator />
|
||||||
Export Current View
|
<DropdownMenuItem
|
||||||
|
onClick={() => setComprehensiveImportOpen(true)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">WordPress Import</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
For WordPress/PMS exports
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setImportDialogOpen(true)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4 mr-2" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Basic User Import</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Simple WordPress users CSV
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasPermission('users.import') && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setImportDialogOpen(true)}
|
|
||||||
className="btn-util-green "
|
|
||||||
>
|
|
||||||
<Upload className="h-5 w-5 mr-2" />
|
|
||||||
Import
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission('users.invite') && (
|
{hasPermission('users.invite') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setInviteDialogOpen(true)}
|
onClick={() => setInviteDialogOpen(true)}
|
||||||
className="btn-util-purple "
|
className=" "
|
||||||
|
variant='outline'
|
||||||
|
|
||||||
>
|
>
|
||||||
<Mail className="h-5 w-5 mr-2" />
|
<Mail className="h-5 w-5 mr-2" />
|
||||||
Invite Member
|
Invite Member
|
||||||
@@ -254,7 +421,8 @@ const AdminMembers = () => {
|
|||||||
{hasPermission('users.create') && (
|
{hasPermission('users.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCreateDialogOpen(true)}
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
className="btn-util-green "
|
className=""
|
||||||
|
variant='outline'
|
||||||
>
|
>
|
||||||
<UserPlus className="h-5 w-5 mr-2" />
|
<UserPlus className="h-5 w-5 mr-2" />
|
||||||
Create Member
|
Create Member
|
||||||
@@ -303,19 +471,20 @@ const AdminMembers = () => {
|
|||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-brand-purple " />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by name or email..."
|
placeholder="Search by name or email..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||||
data-testid="search-members-input"
|
data-testid="search-members-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]" data-testid="status-filter-select">
|
<SelectTrigger className=" rounded-xl border-2 border-[var(--neutral-800)]" data-testid="status-filter-select">
|
||||||
<SelectValue placeholder="Filter by status" />
|
<SelectValue placeholder="Filter by status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -329,8 +498,139 @@ const AdminMembers = () => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='mt-4 flex items-center justify-between'>
|
||||||
|
<div className='text-sm text-brand-purple pl-2'>Showing {filteredUsers.length} of {users.length} </div>
|
||||||
|
{hasPermission('users.export') && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="btn-green "
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<>
|
||||||
|
<Download className=" b h-5 w-5 mr-2 animate-bounce" />
|
||||||
|
Exporting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-5 w-5 mr-2" />
|
||||||
|
Export
|
||||||
|
<ChevronDown className=" h-4 w-4 ml-2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="rounded-xl">
|
||||||
|
<DropdownMenuItem onClick={() => handleExport('all')} className="cursor-pointer">
|
||||||
|
Export All Members
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport('current')} className="cursor-pointer">
|
||||||
|
Export Current View
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Import Job Quick Select */}
|
||||||
|
{hasPermission('users.import') && importJobs.length > 0 && (
|
||||||
|
<Card className="p-4 mb-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Upload className="h-5 w-5 text-brand-purple" />
|
||||||
|
<span
|
||||||
|
className="font-semibold text-brand-purple"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Quick Select from Import
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select
|
||||||
|
value={selectedImportJob || ''}
|
||||||
|
onValueChange={(value) => selectUsersFromImportJob(value || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-64 h-10 bg-white border-[var(--neutral-800)]">
|
||||||
|
<SelectValue placeholder="Select an import job..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">-- None --</SelectItem>
|
||||||
|
{importJobs.map((job) => (
|
||||||
|
<SelectItem key={job.id} value={job.id}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{job.filename || 'Import'} ({job.successful_rows} users)
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(job.started_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{importJobLoading && (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-brand-purple" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk Action Bar */}
|
||||||
|
{selectedUsers.size > 0 && (
|
||||||
|
<Card className="p-4 mb-4 bg-brand-purple text-white rounded-xl sticky top-4 z-10 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} selected
|
||||||
|
{selectedImportJob && (
|
||||||
|
<span className="ml-2 text-sm font-normal opacity-80">
|
||||||
|
(from import job)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
clearSelection();
|
||||||
|
setSelectedImportJob(null);
|
||||||
|
}}
|
||||||
|
className="text-white hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasPermission('users.reset_password') && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setBulkPasswordResetOpen(true)}
|
||||||
|
disabled={bulkActionLoading}
|
||||||
|
className="bg-white text-brand-purple hover:bg-white/90"
|
||||||
|
>
|
||||||
|
{bulkActionLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<KeyRound className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Send Password Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Members List */}
|
{/* Members List */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
@@ -338,17 +638,49 @@ const AdminMembers = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : filteredUsers.length > 0 ? (
|
) : filteredUsers.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Select All Row */}
|
||||||
|
{filteredUsers.length > 0 && hasPermission('users.reset_password') && (
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<Checkbox
|
||||||
|
id="select-all"
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={toggleAllUsers}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="select-all"
|
||||||
|
className="text-sm text-brand-purple cursor-pointer"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Select all ({filteredUsers.length} users)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{filteredUsers.map((user) => {
|
{filteredUsers.map((user) => {
|
||||||
const joinedDate = user.created_at;
|
const joinedDate = user.created_at;
|
||||||
const memberDate = user.member_since;
|
const memberDate = user.member_since;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow"
|
className={`p-6 bg-background rounded-2xl border hover:shadow-md transition-shadow ${selectedUsers.has(user.id)
|
||||||
|
? 'border-brand-purple bg-[var(--lavender-500)]'
|
||||||
|
: 'border-[var(--neutral-800)]'
|
||||||
|
}`}
|
||||||
data-testid={`member-card-${user.id}`}
|
data-testid={`member-card-${user.id}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start flex-wrap gap-4">
|
<div className="flex justify-between items-start flex-wrap gap-4">
|
||||||
<div className="flex items-start gap-4 flex-1">
|
<div className="flex items-start gap-4 flex-1">
|
||||||
|
{/* Selection Checkbox */}
|
||||||
|
{hasPermission('users.reset_password') && (
|
||||||
|
<div className="flex items-center pt-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedUsers.has(user.id)}
|
||||||
|
onCheckedChange={() => toggleUserSelection(user.id)}
|
||||||
|
aria-label={`Select ${user.first_name} ${user.last_name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="h-14 w-14 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold text-lg flex-shrink-0">
|
<div className="h-14 w-14 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold text-lg flex-shrink-0">
|
||||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||||
@@ -532,6 +864,50 @@ const AdminMembers = () => {
|
|||||||
onOpenChange={setImportDialogOpen}
|
onOpenChange={setImportDialogOpen}
|
||||||
onSuccess={refetch}
|
onSuccess={refetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ComprehensiveImportWizard
|
||||||
|
open={comprehensiveImportOpen}
|
||||||
|
onOpenChange={setComprehensiveImportOpen}
|
||||||
|
onSuccess={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateImportWizard
|
||||||
|
open={templateImportOpen}
|
||||||
|
onOpenChange={setTemplateImportOpen}
|
||||||
|
onSuccess={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bulk Password Reset Confirmation Dialog */}
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={bulkPasswordResetOpen}
|
||||||
|
onOpenChange={setBulkPasswordResetOpen}
|
||||||
|
onConfirm={handleBulkPasswordReset}
|
||||||
|
title="Send Password Reset Emails"
|
||||||
|
description={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>
|
||||||
|
You are about to send password reset emails to{' '}
|
||||||
|
<strong>{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''}</strong>.
|
||||||
|
</p>
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>What happens:</strong>
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-blue-700 mt-2 space-y-1 list-disc list-inside">
|
||||||
|
<li>Each user will receive an email with a password reset link</li>
|
||||||
|
<li>The <code className="bg-blue-100 px-1 rounded">force_password_change</code> flag will be set</li>
|
||||||
|
<li>Users must set a new password on their next login</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This action cannot be undone. Continue?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
confirmText={bulkActionLoading ? 'Sending...' : 'Send Emails'}
|
||||||
|
variant="info"
|
||||||
|
loading={bulkActionLoading}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -195,11 +195,11 @@ const AdminPlans = () => {
|
|||||||
placeholder="Search plans..."
|
placeholder="Search plans..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
|
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||||
<SelectValue placeholder="Filter by status" />
|
<SelectValue placeholder="Filter by status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -208,6 +208,13 @@ const AdminPlans = () => {
|
|||||||
<SelectItem value="inactive">Inactive Only</SelectItem>
|
<SelectItem value="inactive">Inactive Only</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Showing {filteredPlans.length} of {plans.length} plans
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
1256
src/pages/admin/AdminRegistrationBuilder.js
Normal file
1256
src/pages/admin/AdminRegistrationBuilder.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,13 @@ export default function AdminSettings() {
|
|||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
publishable_key: '',
|
||||||
secret_key: '',
|
secret_key: '',
|
||||||
webhook_secret: ''
|
webhook_secret: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show/hide sensitive values
|
// Show/hide sensitive values
|
||||||
|
const [showPublishableKey, setShowPublishableKey] = useState(false);
|
||||||
const [showSecretKey, setShowSecretKey] = useState(false);
|
const [showSecretKey, setShowSecretKey] = useState(false);
|
||||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ export default function AdminSettings() {
|
|||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setFormData({
|
setFormData({
|
||||||
|
publishable_key: '',
|
||||||
secret_key: '',
|
secret_key: '',
|
||||||
webhook_secret: ''
|
webhook_secret: ''
|
||||||
});
|
});
|
||||||
@@ -65,17 +68,24 @@ export default function AdminSettings() {
|
|||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setFormData({
|
setFormData({
|
||||||
|
publishable_key: '',
|
||||||
secret_key: '',
|
secret_key: '',
|
||||||
webhook_secret: ''
|
webhook_secret: ''
|
||||||
});
|
});
|
||||||
|
setShowPublishableKey(false);
|
||||||
setShowSecretKey(false);
|
setShowSecretKey(false);
|
||||||
setShowWebhookSecret(false);
|
setShowWebhookSecret(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
if (!formData.secret_key || !formData.webhook_secret) {
|
if (!formData.publishable_key || !formData.secret_key || !formData.webhook_secret) {
|
||||||
toast.error('Both Secret Key and Webhook Secret are required');
|
toast.error('All three keys are required: Publishable Key, Secret Key, and Webhook Secret');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.publishable_key.startsWith('pk_test_') && !formData.publishable_key.startsWith('pk_live_')) {
|
||||||
|
toast.error('Invalid Publishable Key format. Must start with pk_test_ or pk_live_');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,15 +99,25 @@ export default function AdminSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check environment consistency
|
||||||
|
const pkIsLive = formData.publishable_key.startsWith('pk_live_');
|
||||||
|
const skIsLive = formData.secret_key.startsWith('sk_live_');
|
||||||
|
if (pkIsLive !== skIsLive) {
|
||||||
|
toast.error('Publishable Key and Secret Key must be from the same environment (both test or both live)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.put('/admin/settings/stripe', formData);
|
await api.put('/admin/settings/stripe', formData);
|
||||||
toast.success('Stripe settings updated successfully');
|
toast.success('Stripe settings updated successfully');
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setFormData({
|
setFormData({
|
||||||
|
publishable_key: '',
|
||||||
secret_key: '',
|
secret_key: '',
|
||||||
webhook_secret: ''
|
webhook_secret: ''
|
||||||
});
|
});
|
||||||
|
setShowPublishableKey(false);
|
||||||
setShowSecretKey(false);
|
setShowSecretKey(false);
|
||||||
setShowWebhookSecret(false);
|
setShowWebhookSecret(false);
|
||||||
// Refresh status
|
// Refresh status
|
||||||
@@ -157,6 +177,31 @@ export default function AdminSettings() {
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
/* Edit Mode */
|
/* Edit Mode */
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Publishable Key Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="publishable_key">Stripe Publishable Key</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="publishable_key"
|
||||||
|
type={showPublishableKey ? 'text' : 'password'}
|
||||||
|
value={formData.publishable_key}
|
||||||
|
onChange={(e) => setFormData({ ...formData, publishable_key: e.target.value })}
|
||||||
|
placeholder="pk_test_... or pk_live_..."
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPublishableKey(!showPublishableKey)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{showPublishableKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Get this from your Stripe Dashboard → Developers → API keys (Publishable key)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Secret Key Input */}
|
{/* Secret Key Input */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="secret_key">Stripe Secret Key</Label>
|
<Label htmlFor="secret_key">Stripe Secret Key</Label>
|
||||||
@@ -178,7 +223,7 @@ export default function AdminSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Get this from your Stripe Dashboard → Developers → API keys
|
Get this from your Stripe Dashboard → Developers → API keys (Secret key)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,7 +312,7 @@ export default function AdminSettings() {
|
|||||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">Environment</p>
|
<p className="font-semibold text-gray-900">Environment</p>
|
||||||
<p className="text-sm text-gray-600">Detected from secret key prefix</p>
|
<p className="text-sm text-gray-600">Detected from key prefixes</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
|
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
|
||||||
stripeStatus.environment === 'live'
|
stripeStatus.environment === 'live'
|
||||||
@@ -278,6 +323,20 @@ export default function AdminSettings() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">Publishable Key</p>
|
||||||
|
<p className="text-sm text-gray-600 font-mono">
|
||||||
|
{stripeStatus.publishable_key_prefix}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stripeStatus.publishable_key_set ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">Secret Key</p>
|
<p className="font-semibold text-gray-900">Secret Key</p>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const AdminStaff = () => {
|
|||||||
{hasPermission('users.create') && (
|
{hasPermission('users.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setInviteDialogOpen(true)}
|
onClick={() => setInviteDialogOpen(true)}
|
||||||
className="btn-util-purple h-12 px-6"
|
className="btn-purple px-6"
|
||||||
>
|
>
|
||||||
<Mail className="h-5 w-5 mr-2" />
|
<Mail className="h-5 w-5 mr-2" />
|
||||||
Invite Staff
|
Invite Staff
|
||||||
@@ -92,7 +92,7 @@ const AdminStaff = () => {
|
|||||||
{hasPermission('users.create') && (
|
{hasPermission('users.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCreateDialogOpen(true)}
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
className="btn-util-green h-12 px-6"
|
className="btn-green px-6"
|
||||||
>
|
>
|
||||||
<UserPlus className="h-5 w-5 mr-2" />
|
<UserPlus className="h-5 w-5 mr-2" />
|
||||||
Create Staff
|
Create Staff
|
||||||
@@ -163,12 +163,12 @@ const AdminStaff = () => {
|
|||||||
placeholder="Search by name or email..."
|
placeholder="Search by name or email..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||||
data-testid="search-staff-input"
|
data-testid="search-staff-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]" data-testid="role-filter-select">
|
<SelectTrigger className=" rounded-xl border-2 border-[var(--neutral-800)]" data-testid="role-filter-select">
|
||||||
<SelectValue placeholder="Filter by role" />
|
<SelectValue placeholder="Filter by role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -180,6 +180,11 @@ const AdminStaff = () => {
|
|||||||
<SelectItem value="media">Media</SelectItem>
|
<SelectItem value="media">Media</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Showing {filteredUsers.length} of {users.length} subscriptions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ Proceed with activation?`;
|
|||||||
{hasPermission('users.create') && (
|
{hasPermission('users.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCreateDialogOpen(true)}
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
className="btn-util-green "
|
className="btn-green "
|
||||||
>
|
>
|
||||||
<Repeat className="h-5 w-5 mr-2" />
|
<Repeat className="h-5 w-5 mr-2" />
|
||||||
Create Subscription
|
Create Subscription
|
||||||
@@ -459,7 +459,7 @@ Proceed with activation?`;
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* second row */}
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
|
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
|
||||||
@@ -496,6 +496,7 @@ Proceed with activation?`;
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Subscriptions Table */}
|
{/* Subscriptions Table */}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ConfirmationDialog from '../../components/ConfirmationDialog';
|
|||||||
import ChangeRoleDialog from '../../components/ChangeRoleDialog';
|
import ChangeRoleDialog from '../../components/ChangeRoleDialog';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import TransactionHistory from '../../components/TransactionHistory';
|
import TransactionHistory from '../../components/TransactionHistory';
|
||||||
|
import AdminPaymentMethodsPanel from '../../components/admin/AdminPaymentMethodsPanel';
|
||||||
|
|
||||||
const AdminUserView = () => {
|
const AdminUserView = () => {
|
||||||
const { userId } = useParams();
|
const { userId } = useParams();
|
||||||
@@ -417,6 +418,14 @@ const AdminUserView = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Payment Methods Panel */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<AdminPaymentMethodsPanel
|
||||||
|
userId={userId}
|
||||||
|
userName={`${user.first_name} ${user.last_name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Additional Details */}
|
{/* Additional Details */}
|
||||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
|||||||
@@ -34,7 +34,20 @@ import {
|
|||||||
PaginationEllipsis,
|
PaginationEllipsis,
|
||||||
} from '../../components/ui/pagination';
|
} from '../../components/ui/pagination';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X, FileText, XCircle } from 'lucide-react';
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Search,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
X,
|
||||||
|
FileText,
|
||||||
|
XCircle,
|
||||||
|
Users,
|
||||||
|
Mail,
|
||||||
|
ShieldCheck,
|
||||||
|
CreditCard
|
||||||
|
} from 'lucide-react';
|
||||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||||
import RejectionDialog from '../../components/RejectionDialog';
|
import RejectionDialog from '../../components/RejectionDialog';
|
||||||
@@ -341,35 +354,29 @@ const AdminValidations = () => {
|
|||||||
Quick Overview
|
Quick Overview
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
<StatCard
|
|
||||||
title="Total Pending"
|
|
||||||
value={loading ? '-' : pendingUsers.length}
|
|
||||||
icon={CheckCircle}
|
|
||||||
iconBgClass="text-brand-purple"
|
|
||||||
dataTestId="stat-total-users"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Awaiting Email"
|
title="Awaiting Email"
|
||||||
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
|
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
|
||||||
icon={CheckCircle}
|
icon={Mail}
|
||||||
iconBgClass="text-brand-purple"
|
iconBgClass="text-brand-pink"
|
||||||
dataTestId="stat-total-users"
|
dataTestId="stat-total-users"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Pending Validation"
|
title="Pending Validation"
|
||||||
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
|
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
|
||||||
icon={CheckCircle}
|
icon={ShieldCheck}
|
||||||
iconBgClass="text-brand-purple"
|
iconBgClass="text-success"
|
||||||
dataTestId="stat-pending-validation"
|
dataTestId="stat-pending-validation"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Payment Pending"
|
title="Payment Pending"
|
||||||
value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
|
value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
|
||||||
icon={CheckCircle}
|
icon={CreditCard}
|
||||||
iconBgClass="text-brand-purple"
|
iconBgClass="text-accent"
|
||||||
dataTestId="stat-payment-pending"
|
dataTestId="stat-payment-pending"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -381,7 +388,13 @@ const AdminValidations = () => {
|
|||||||
dataTestId="stat-rejected"
|
dataTestId="stat-rejected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
title="Total Pending"
|
||||||
|
value={loading ? '-' : pendingUsers.filter(user => ['pending_email', 'pending_validation', 'pre_validated', 'payment_pending',].includes(user.status)).length}
|
||||||
|
icon={Users}
|
||||||
|
iconBgClass="text-brand-purple"
|
||||||
|
dataTestId="stat-total-users"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -394,12 +407,12 @@ const AdminValidations = () => {
|
|||||||
placeholder="Search by name, email, or phone..."
|
placeholder="Search by name, email, or phone..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
|
<SelectTrigger className=" rounded-xl border-2 border-[var(--neutral-800)]">
|
||||||
<SelectValue placeholder="Filter by status" />
|
<SelectValue placeholder="Filter by status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="">
|
<SelectContent className="">
|
||||||
@@ -410,6 +423,13 @@ const AdminValidations = () => {
|
|||||||
<SelectItem value="rejected" >Rejected</SelectItem>
|
<SelectItem value="rejected" >Rejected</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Showing {filteredUsers.length} of {pendingUsers.length} pending validations
|
||||||
|
</div>
|
||||||
|
{/*TODO: <div>Export button here?</div> */}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function Bylaws() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ const EventGallery = () => {
|
|||||||
// Event Gallery Grid View
|
// Event Gallery Grid View
|
||||||
if (!selectedEvent) {
|
if (!selectedEvent) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function Financials() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||||
|
|||||||
@@ -89,6 +89,20 @@ export default function MemberCalendar() {
|
|||||||
setRsvpLoading(false);
|
setRsvpLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const getReadableTextColor = (hex) => {
|
||||||
|
const cleaned = hex.replace('#', '');
|
||||||
|
const r = parseInt(cleaned.substring(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(cleaned.substring(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(cleaned.substring(4, 6), 16) / 255;
|
||||||
|
|
||||||
|
const srgb = [r, g, b].map((c) =>
|
||||||
|
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||||
|
);
|
||||||
|
const luminance = 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
|
||||||
|
|
||||||
|
// tweak threshold to taste
|
||||||
|
return luminance > 0.6 ? 'var(--brand-purple)' : 'white';
|
||||||
|
};
|
||||||
|
|
||||||
const eventStyleGetter = (event) => {
|
const eventStyleGetter = (event) => {
|
||||||
const rsvpStatus = event.resource?.user_rsvp_status;
|
const rsvpStatus = event.resource?.user_rsvp_status;
|
||||||
@@ -99,12 +113,16 @@ export default function MemberCalendar() {
|
|||||||
if (rsvpStatus === 'yes') {
|
if (rsvpStatus === 'yes') {
|
||||||
backgroundColor = '#81B29A';
|
backgroundColor = '#81B29A';
|
||||||
borderColor = '#66927e';
|
borderColor = '#66927e';
|
||||||
|
|
||||||
} else if (rsvpStatus === 'no') {
|
} else if (rsvpStatus === 'no') {
|
||||||
backgroundColor = '#9ca3af';
|
backgroundColor = '#9ca3af';
|
||||||
borderColor = '#6b7280';
|
borderColor = '#6b7280';
|
||||||
|
|
||||||
} else if (rsvpStatus === 'maybe') {
|
} else if (rsvpStatus === 'maybe') {
|
||||||
backgroundColor = '#fb923c';
|
backgroundColor = '#fb923c';
|
||||||
borderColor = '#ea580c';
|
borderColor = '#ea580c';
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -114,7 +132,7 @@ export default function MemberCalendar() {
|
|||||||
borderWidth: '2px',
|
borderWidth: '2px',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
color: 'white',
|
color: getReadableTextColor(backgroundColor),
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
@@ -136,7 +154,7 @@ export default function MemberCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||||
@@ -175,7 +193,7 @@ export default function MemberCalendar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
<Card className="p-6 bg-background text-brand-purple rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||||
<Calendar
|
<Calendar
|
||||||
localizer={localizer}
|
localizer={localizer}
|
||||||
events={calendarEvents}
|
events={calendarEvents}
|
||||||
@@ -275,9 +293,10 @@ export default function MemberCalendar() {
|
|||||||
onClick={() => handleRSVP('yes')}
|
onClick={() => handleRSVP('yes')}
|
||||||
disabled={rsvpLoading}
|
disabled={rsvpLoading}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
variant='outline'
|
||||||
className={`rounded-full px-6 flex items-center gap-2 ${selectedEvent.user_rsvp_status === 'yes'
|
className={`rounded-full px-6 flex items-center gap-2 ${selectedEvent.user_rsvp_status === 'yes'
|
||||||
? 'bg-[var(--green-light)] text-white hover:bg-[var(--green-muted)]'
|
? 'border-success bg-success/20 text-success'
|
||||||
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--neutral-400:)]'
|
: 'border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
@@ -303,7 +322,7 @@ export default function MemberCalendar() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={`rounded-full px-6 flex items-center gap-2 border-2 ${selectedEvent.user_rsvp_status === 'no'
|
className={`rounded-full px-6 flex items-center gap-2 border-2 ${selectedEvent.user_rsvp_status === 'no'
|
||||||
? 'border-gray-400 bg-gray-100 text-gray-700'
|
? 'border-gray-400 bg-gray-100 text-gray-700'
|
||||||
: 'border-gray-400 text-gray-600 hover:bg-gray-50'
|
: 'border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import Navbar from '../../components/Navbar';
|
import Navbar from '../../components/Navbar';
|
||||||
import MemberFooter from '../../components/MemberFooter';
|
import MemberFooter from '../../components/MemberFooter';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Input } from '../../components/ui/input';
|
import { Input } from '../../components/ui/input';
|
||||||
import { Badge } from '../../components/ui/badge';
|
import { Badge } from '../../components/ui/badge';
|
||||||
@@ -13,12 +14,33 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '../../components/ui/dialog';
|
} from '../../components/ui/dialog';
|
||||||
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
|
import {
|
||||||
|
User,
|
||||||
|
Search,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Heart,
|
||||||
|
Facebook,
|
||||||
|
Instagram,
|
||||||
|
Twitter,
|
||||||
|
Linkedin,
|
||||||
|
CircleUserRound,
|
||||||
|
Calendar,
|
||||||
|
ChevronsRight,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronsLeft,
|
||||||
|
UserRound
|
||||||
|
} from 'lucide-react';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import MemberCard from '../../components/MemberCard';
|
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';
|
||||||
|
import { useThemeConfig } from '@/context/ThemeConfigContext';
|
||||||
|
|
||||||
|
|
||||||
const MembersDirectory = () => {
|
const MembersDirectory = () => {
|
||||||
const [selectedMember, setSelectedMember] = useState(null);
|
const [selectedMember, setSelectedMember] = useState(null);
|
||||||
@@ -27,6 +49,7 @@ const MembersDirectory = () => {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
const { tiers } = useMemberTiers();
|
const { tiers } = useMemberTiers();
|
||||||
|
const { isFieldEnabled } = useDirectoryConfig();
|
||||||
const allowedRoles = useMemo(() => [], []);
|
const allowedRoles = useMemo(() => [], []);
|
||||||
const normalizeStatus = useCallback((status) => {
|
const normalizeStatus = useCallback((status) => {
|
||||||
if (typeof status === 'string') {
|
if (typeof status === 'string') {
|
||||||
@@ -34,6 +57,12 @@ const MembersDirectory = () => {
|
|||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { getLogoUrl } = useThemeConfig();
|
||||||
|
// Get logo URL from theme config (with fallback to default)
|
||||||
|
const logo = getLogoUrl();
|
||||||
|
|
||||||
|
|
||||||
const normalizeMembers = useCallback(
|
const normalizeMembers = useCallback(
|
||||||
(data) => {
|
(data) => {
|
||||||
const list = Array.isArray(data)
|
const list = Array.isArray(data)
|
||||||
@@ -79,6 +108,29 @@ const MembersDirectory = () => {
|
|||||||
fetchErrorMessage: 'Failed to load members directory. Please try again.',
|
fetchErrorMessage: 'Failed to load members directory. Please try again.',
|
||||||
onFetchError: handleFetchError
|
onFetchError: handleFetchError
|
||||||
});
|
});
|
||||||
|
const [searchInput, setSearchInput] = useState(searchQuery);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
const nextQuery = searchInput.trim();
|
||||||
|
setSearchInput(nextQuery);
|
||||||
|
setSearchQuery(nextQuery);
|
||||||
|
}, [searchInput, setSearchQuery]);
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchInput('');
|
||||||
|
setSearchQuery('');
|
||||||
|
}, [setSearchQuery]);
|
||||||
|
|
||||||
|
const handleSearchKeyDown = useCallback((event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleClearSearch();
|
||||||
|
}
|
||||||
|
}, [handleClearSearch, handleSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -135,7 +187,7 @@ const MembersDirectory = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)]">
|
<div className="min-h-screen bg-gradient-to-bl from-white to-muted">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto py-12">
|
<div className="max-w-7xl mx-auto py-12">
|
||||||
@@ -144,39 +196,62 @@ const MembersDirectory = () => {
|
|||||||
<div className='px-9'>
|
<div className='px-9'>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="m-8 mt-14 flex flex-col sm:flex-row justify-between items-center ">
|
<div className="my-8 mb-8 mt-14 gap-4 flex flex-col sm:flex-row items-center ">
|
||||||
|
<Link to="/dashboard">
|
||||||
|
<img src={logo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className='flex flex-col items-start'>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
LOAF Members
|
Members Directory
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
<span className='text-foreground'>Number of current members in the directory: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span>
|
<p className="text-lg font-bold flex gap-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
<UserRound className='text-brand-purple/80' />
|
||||||
|
<span className='text-foreground'>Active Members Count: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="mb-24 mx-10">
|
<div className=" w-full flex gap-3">
|
||||||
<div className="relative w-full ">
|
<div className="relative w-full ">
|
||||||
|
|
||||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or bio..."
|
placeholder="Search by name or bio..."
|
||||||
value={searchQuery}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
className="pl-12 pr-4 py-6 text-3xl font-medium bg-background border-foreground rounded-full focus:border-brand-purple focus:ring-brand-purple "
|
className="pl-12 pr-4 py-6 text-3xl font-medium bg-background border-foreground rounded-full focus:border-brand-purple focus:ring-brand-purple "
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size='lg'
|
||||||
|
className='self-center bg-brand-purple hover:bg-brand-purple/90 px-16'
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='text-brand-purple/80 bg-transparent hover:bg-transparent hover:underline shadow-none p-2 self-center'
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<p className="mt-3 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="mt-3 text-sm ml-6 text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif " }}>
|
||||||
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
|
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className='mb-24'></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/* Border Decoration */}
|
{/* Border Decoration */}
|
||||||
|
<Border className='' />
|
||||||
<Border />
|
|
||||||
|
|
||||||
{/* Members Grid */}
|
{/* Members Grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -203,27 +278,26 @@ const MembersDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Border Decoration */}
|
{/* Border Decoration */}
|
||||||
<Border yaxis="true" />
|
<Border yaxis="true" />
|
||||||
{/* todo: use badge to display if member */}
|
{/* todo: use badge to display if member */}
|
||||||
{/* Info Card */}
|
{/* Info Card */}
|
||||||
{!loading && members.length > 0 && (
|
{!loading && members.length > 0 && (
|
||||||
<Card className="mt-12 p-6 bg-[var(--lavender-500)] border-[var(--neutral-800)]">
|
<Card className="mt-12 p-4 bg-[var(--lavender-500)] border-[var(--neutral-800)]">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
|
<div className="bg-[var(--neutral-800)]/20 rounded-lg">
|
||||||
<User className="h-6 w-6 text-brand-purple " />
|
<CircleUserRound className="size-20 text-brand-lavender " />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className='self-center'>
|
||||||
<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: "'Poppins', sans-serif" }}>
|
||||||
Want to appear in the directory?
|
Want to appear in the directory?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-brand-purple/90 font-medium " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '}
|
Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '}
|
||||||
<a href="/members/profile" className="text-[var(--orange-light)] hover:underline font-medium">
|
|
||||||
|
<Link to="/profile" className="text-[var(--orange-light)] hover:underline font-medium">
|
||||||
Edit your profile →
|
Edit your profile →
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,7 +314,7 @@ const MembersDirectory = () => {
|
|||||||
{selectedMember.first_name} {selectedMember.last_name}
|
{selectedMember.first_name} {selectedMember.last_name}
|
||||||
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
|
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{selectedMember.directory_partner_name && (
|
{isFieldEnabled('directory_partner_name') && selectedMember.directory_partner_name && (
|
||||||
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
||||||
<span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span>
|
<span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span>
|
||||||
@@ -250,7 +324,7 @@ const MembersDirectory = () => {
|
|||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* Bio */}
|
{/* Bio */}
|
||||||
{selectedMember.directory_bio && (
|
{isFieldEnabled('directory_bio') && selectedMember.directory_bio && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
About
|
About
|
||||||
@@ -262,12 +336,13 @@ const MembersDirectory = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Contact Information */}
|
{/* Contact Information */}
|
||||||
|
{(isFieldEnabled('directory_email') || isFieldEnabled('directory_phone') || isFieldEnabled('directory_address') || isFieldEnabled('directory_dob')) && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
Contact Information
|
Contact Information
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{selectedMember.directory_email && (
|
{isFieldEnabled('directory_email') && selectedMember.directory_email && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||||||
<Mail className="h-5 w-5 text-brand-purple " />
|
<Mail className="h-5 w-5 text-brand-purple " />
|
||||||
@@ -285,7 +360,7 @@ const MembersDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMember.directory_phone && (
|
{isFieldEnabled('directory_phone') && selectedMember.directory_phone && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||||||
<Phone className="h-5 w-5 text-brand-purple " />
|
<Phone className="h-5 w-5 text-brand-purple " />
|
||||||
@@ -303,7 +378,7 @@ const MembersDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMember.directory_address && (
|
{isFieldEnabled('directory_address') && selectedMember.directory_address && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||||||
<MapPin className="h-5 w-5 text-brand-purple " />
|
<MapPin className="h-5 w-5 text-brand-purple " />
|
||||||
@@ -317,7 +392,7 @@ const MembersDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMember.directory_dob && (
|
{isFieldEnabled('directory_dob') && selectedMember.directory_dob && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||||||
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
||||||
@@ -332,9 +407,10 @@ const MembersDirectory = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Volunteer Interests */}
|
{/* Volunteer Interests */}
|
||||||
{selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
|
{isFieldEnabled('volunteer_interests') && selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
Volunteer Interests
|
Volunteer Interests
|
||||||
@@ -353,7 +429,7 @@ const MembersDirectory = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Social Media */}
|
{/* Social Media */}
|
||||||
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
|
{isFieldEnabled('social_media') && (selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
|
||||||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
|
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
@@ -426,16 +502,16 @@ const MembersDirectory = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => setCurrentPage(1)}
|
onClick={() => setCurrentPage(1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="bg-[var(--neutral-800)] rounded-full text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white"
|
className="bg-transparent text-brand-purple shadow-none "
|
||||||
>
|
>
|
||||||
First Page
|
<ChevronsLeft />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="bg-[var(--neutral-800)] rounded-full text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white"
|
className="bg-transparent text-brand-purple shadow-none"
|
||||||
>
|
>
|
||||||
Previous
|
<ChevronLeft size={64} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, index) => {
|
{Array.from({ length: totalPages }, (_, index) => {
|
||||||
@@ -447,7 +523,7 @@ const MembersDirectory = () => {
|
|||||||
onClick={() => setCurrentPage(pageNumber)}
|
onClick={() => setCurrentPage(pageNumber)}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full"
|
? "bg-brand-purple text-white rounded-full hover:bg-brand-purple"
|
||||||
: "bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white rounded-full "
|
: "bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white rounded-full "
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -459,16 +535,17 @@ const MembersDirectory = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple rounded-full hover:text-white"
|
className="bg-transparent text-brand-purple shadow-none"
|
||||||
>
|
>
|
||||||
Next
|
<ChevronRight />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCurrentPage(totalPages)}
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple rounded-full hover:text-white"
|
className="bg-transparent text-brand-purple shadow-none"
|
||||||
>
|
>
|
||||||
Last Page
|
<ChevronsRight />
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default function NewsletterArchive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gradient-to-b from-[var(--neutral-100)] to-[var(--neutral-800)]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
@apply border border-primary border-2 text-primary shadow-sm hover:bg-primary/10 rounded-full disabled:opacity-50 px-6 transition-transform;
|
@apply border-brand-purple border-2 text-brand-purple shadow-sm hover:bg-brand-purple/10 rounded-full disabled:opacity-50 px-6 transition-transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-accent {
|
.btn-accent {
|
||||||
@@ -58,6 +58,10 @@
|
|||||||
.btn-green {
|
.btn-green {
|
||||||
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-full px-6;
|
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-full px-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-purple {
|
||||||
|
@apply bg-brand-purple text-background shadow hover:bg-brand-purple/90 rounded-full px-6 disabled:opacity-50 px-6 transition-transform
|
||||||
|
}
|
||||||
.btn-util-green {
|
.btn-util-green {
|
||||||
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-xl h-12 px-6;
|
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-xl h-12 px-6;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_URL = process.env.REACT_APP_BACKEND_URL;
|
const API_URL = process.env.REACT_APP_BACKEND_URL;
|
||||||
|
const BASENAME = process.env.REACT_APP_BASENAME || '';
|
||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: `${API_URL}/api`,
|
baseURL: `${API_URL}/api`,
|
||||||
@@ -30,6 +31,35 @@ api.interceptors.response.use(
|
|||||||
async (error) => {
|
async (error) => {
|
||||||
const config = error.config;
|
const config = error.config;
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized - session expired
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
// Don't redirect if it's a login request or auth check
|
||||||
|
const isAuthRequest = config?.url?.includes('/auth/login') ||
|
||||||
|
config?.url?.includes('/auth/me') ||
|
||||||
|
config?.url?.includes('/auth/permissions');
|
||||||
|
|
||||||
|
if (!isAuthRequest) {
|
||||||
|
console.warn('[API] Session expired - redirecting to login');
|
||||||
|
|
||||||
|
// Clear auth state
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
|
||||||
|
// Dispatch custom event for components to react
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:session-expired'));
|
||||||
|
|
||||||
|
// Redirect to login with session expired message
|
||||||
|
// Use replace to prevent back button issues
|
||||||
|
// Include BASENAME to support subpath deployments
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
const loginPath = `${BASENAME}/login?session=expired`;
|
||||||
|
if (!currentPath.includes('/login')) {
|
||||||
|
window.location.replace(loginPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
// Don't retry if we've already retried or if it's a client error (4xx)
|
// Don't retry if we've already retried or if it's a client error (4xx)
|
||||||
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
|
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
|
||||||
console.error('[API] Request failed:', {
|
console.error('[API] Request failed:', {
|
||||||
|
|||||||
Reference in New Issue
Block a user