14 Commits

Author SHA1 Message Date
kayela
f0ee505339 restructured layout 2026-02-02 16:36:52 -06:00
kayela
21338f1541 feat: restruction of admin sidebar, button slightly adjusted, member tiers header added, routing for sidbar adjusted 2026-02-01 16:44:55 -06:00
kayela
da366272b4 fix: fixed total pending display 2026-02-01 15:36:43 -06:00
kayela
af27190e29 Phone formatting works, start card moved, registration styling changed 2026-02-01 15:16:12 -06:00
kayela
235156a9ee Merge branch 'features' into dev 2026-02-01 10:44:12 -06:00
Koncept Kit
68ee22c124 Changes 2026-02-01 19:53:45 +07:00
Koncept Kit
5d085153f6 1. New Components- src/components/PaymentMethodCard.js - Displays individual payment method- src/components/AddPaymentMethodDialog.js - Stripe Elements dialog for adding cards- src/components/PaymentMethodsSection.js - Main payment methods UI- src/components/PasswordConfirmDialog.js - Admin password re-entry dialog- src/components/admin/AdminPaymentMethodsPanel.js - Admin panel for user payment methods2. Profile Integration (src/pages/Profile.js)- Replaced placeholder Payment Method section with PaymentMethodsSection3. Admin Integration (src/pages/admin/AdminUserView.js)- Added AdminPaymentMethodsPanel to user detail view 2026-01-31 01:09:37 +07:00
kayela
01a3c38085 fix: button text now visable 2026-01-30 09:50:33 -06:00
kayela
7152382dca fix: member directory link works and stat card changes 2026-01-30 09:38:42 -06:00
kayela
529d3d4697 Merge branch 'theme-provider' into dev 2026-01-29 21:50:28 -06:00
kayela
7eef62560e feat: staff can edit registration responses 2026-01-29 21:49:25 -06:00
kayela
f70a133e18 feat: tabs layout for edit profile 2026-01-29 20:49:13 -06:00
kayela
4423576fa2 Merge branch 'theme-provider' into dev 2026-01-29 00:01:43 -06:00
a1c68eedc2 Merge pull request 'theme-provider' (#22) from theme-provider into dev
Reviewed-on: #22
2026-01-28 01:50:41 +00:00
20 changed files with 4690 additions and 691 deletions

View File

@@ -47,6 +47,7 @@ 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 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 +239,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 +307,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,7 +318,6 @@ 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> </Route>

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import React from 'react'; import 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 } 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 },
]; ];
const SettingsTabs = () => { const SettingsTabs = () => {

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -9,12 +9,77 @@ import {
} from './ui/dialog'; } from './ui/dialog';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Checkbox } from './ui/checkbox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react'; import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react';
import StatusBadge from './StatusBadge'; import StatusBadge from './StatusBadge';
import api from '../utils/api';
import { toast } from 'sonner';
const ViewRegistrationDialog = ({ open, onOpenChange, user }) => { const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
if (!user) return null; if (!user) return null;
const [formData, setFormData] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const autoSaveTimeoutRef = useRef(null);
const pendingSaveRef = useRef(false);
const leadSourceOptions = [
'Current member',
'Friend',
'OutSmart Magazine',
'Search engine (Google etc.)',
"I've known about LOAF for a long time",
'Other'
];
const volunteerOptions = [
'Welcoming new members at events',
'Sending out birthday cards',
'Care Team Calls',
'Sharing ideas for events',
'Researching grants',
'Applying for grants',
'Assisting with TeatherLOAFers',
'Assisting with ActiveLOAFers',
'Assisting with weekday Lunch Bunch',
'Uploading Photos to the Website',
'Assisting with eNewsletter',
'Other administrative task'
];
useEffect(() => {
if (!open || !user) return;
const nextFormData = {
lead_sources: Array.isArray(user.lead_sources) ? user.lead_sources : [],
partner_first_name: user.partner_first_name || '',
partner_last_name: user.partner_last_name || '',
partner_is_member: Boolean(user.partner_is_member),
partner_plan_to_become_member: Boolean(user.partner_plan_to_become_member),
newsletter_publish_name: Boolean(user.newsletter_publish_name),
newsletter_publish_photo: Boolean(user.newsletter_publish_photo),
newsletter_publish_birthday: Boolean(user.newsletter_publish_birthday),
newsletter_publish_none: Boolean(user.newsletter_publish_none),
referred_by_member_name: user.referred_by_member_name || '',
volunteer_interests: Array.isArray(user.volunteer_interests) ? user.volunteer_interests : [],
scholarship_requested: Boolean(user.scholarship_requested),
scholarship_reason: user.scholarship_reason || ''
};
setFormData(nextFormData);
setHasUnsavedChanges(false);
}, [open, user]);
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, []);
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return '—'; if (!dateString) return '—';
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString('en-US', {
@@ -44,6 +109,91 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
return phone; return phone;
}; };
const saveProfile = async (showToast = true) => {
if (!formData) return;
if (isSaving) {
pendingSaveRef.current = true;
return;
}
setIsSaving(true);
try {
await api.put('/users/profile', {
lead_sources: formData.lead_sources,
partner_first_name: formData.partner_first_name,
partner_last_name: formData.partner_last_name,
partner_is_member: formData.partner_is_member,
partner_plan_to_become_member: formData.partner_plan_to_become_member,
newsletter_publish_name: formData.newsletter_publish_name,
newsletter_publish_photo: formData.newsletter_publish_photo,
newsletter_publish_birthday: formData.newsletter_publish_birthday,
newsletter_publish_none: formData.newsletter_publish_none,
referred_by_member_name: formData.referred_by_member_name,
volunteer_interests: formData.volunteer_interests,
scholarship_requested: formData.scholarship_requested,
scholarship_reason: formData.scholarship_reason
});
setHasUnsavedChanges(false);
if (showToast) {
toast.success('Registration details saved');
}
} catch (error) {
if (showToast) {
toast.error(error.response?.data?.detail || 'Failed to save registration details');
}
} finally {
setIsSaving(false);
if (pendingSaveRef.current) {
pendingSaveRef.current = false;
saveProfile(showToast);
}
}
};
const scheduleAutoSave = () => {
setHasUnsavedChanges(true);
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
saveProfile(false);
}, 800);
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => {
const next = { ...prev, [name]: value };
return next;
});
scheduleAutoSave();
};
const handleCheckboxChange = (name, checked) => {
setFormData(prev => ({ ...prev, [name]: checked }));
scheduleAutoSave();
};
const handleLeadSourceChange = (source) => {
setFormData(prev => {
const sources = prev.lead_sources.includes(source)
? prev.lead_sources.filter((item) => item !== source)
: [...prev.lead_sources, source];
return { ...prev, lead_sources: sources };
});
scheduleAutoSave();
};
const handleVolunteerChange = (option) => {
setFormData(prev => {
const interests = prev.volunteer_interests.includes(option)
? prev.volunteer_interests.filter((item) => item !== option)
: [...prev.volunteer_interests, option];
return { ...prev, volunteer_interests: interests };
});
scheduleAutoSave();
};
const InfoRow = ({ icon: Icon, label, value }) => ( const InfoRow = ({ icon: Icon, label, value }) => (
<div className="flex items-start gap-3 py-3 border-b border-[var(--neutral-800)] last:border-b-0"> <div className="flex items-start gap-3 py-3 border-b border-[var(--neutral-800)] last:border-b-0">
<div className="h-10 w-10 rounded-lg bg-[var(--lavender-400)] flex items-center justify-center flex-shrink-0"> <div className="h-10 w-10 rounded-lg bg-[var(--lavender-400)] flex items-center justify-center flex-shrink-0">
@@ -109,10 +259,214 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
Registration Details Registration Details
</h3> </h3>
<InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} /> <InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} />
<InfoRow icon={UserCheck} label="Referred By" value={user.referred_by_member_name} /> <InfoRow icon={UserCheck} label="Referred By" value={formData?.referred_by_member_name} />
<InfoRow icon={Clock} label="Email Verification Expires" value={formatDateTime(user.email_verification_expires_at)} /> <InfoRow icon={Clock} label="Email Verification Expires" value={formatDateTime(user.email_verification_expires_at)} />
</Card> </Card>
{formData && (
<>
{/* How Did You Hear About Us */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
How Did You Hear About Us? *
</h3>
<div className="space-y-3">
{leadSourceOptions.map((source) => (
<div key={source} className="flex items-center space-x-2">
<Checkbox
id={`lead_${source}`}
checked={formData.lead_sources.includes(source)}
onCheckedChange={() => handleLeadSourceChange(source)}
/>
<Label htmlFor={`lead_${source}`} className="text-base cursor-pointer">
{source}
</Label>
</div>
))}
</div>
</Card>
{/* Partner Information */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Partner Information (Optional)
</h3>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div>
<Label htmlFor="partner_first_name">Partner First Name</Label>
<Input
id="partner_first_name"
name="partner_first_name"
value={formData.partner_first_name}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
<div>
<Label htmlFor="partner_last_name">Partner Last Name</Label>
<Input
id="partner_last_name"
name="partner_last_name"
value={formData.partner_last_name}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="partner_is_member"
checked={formData.partner_is_member}
onCheckedChange={(checked) => handleCheckboxChange('partner_is_member', checked)}
/>
<Label htmlFor="partner_is_member" className="text-base cursor-pointer">
Is your partner already a member?
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="partner_plan_to_become_member"
checked={formData.partner_plan_to_become_member}
onCheckedChange={(checked) => handleCheckboxChange('partner_plan_to_become_member', checked)}
/>
<Label htmlFor="partner_plan_to_become_member" className="text-base cursor-pointer">
Does your partner plan to become a member?
</Label>
</div>
</div>
</Card>
{/* Newsletter Preferences */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Newsletter Publication Preferences *
</h3>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Please check what information may be published in LOAF Newsletter
</p>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_name"
checked={formData.newsletter_publish_name}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_name', checked)}
/>
<Label htmlFor="newsletter_publish_name" className="text-base cursor-pointer">
Name
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_photo"
checked={formData.newsletter_publish_photo}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_photo', checked)}
/>
<Label htmlFor="newsletter_publish_photo" className="text-base cursor-pointer">
Photo (added later in profile)
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_birthday"
checked={formData.newsletter_publish_birthday}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_birthday', checked)}
/>
<Label htmlFor="newsletter_publish_birthday" className="text-base cursor-pointer">
Birthday
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_none"
checked={formData.newsletter_publish_none}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_none', checked)}
/>
<Label htmlFor="newsletter_publish_none" className="text-base cursor-pointer">
Do not publish any of my information
</Label>
</div>
</div>
</Card>
{/* Referral */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Referral
</h3>
<div>
<Label htmlFor="referred_by_member_name">Name of a LOAF Member who already knows you</Label>
<Input
id="referred_by_member_name"
name="referred_by_member_name"
value={formData.referred_by_member_name}
onChange={handleInputChange}
placeholder="Enter member name or email"
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
If referred by a current member, you may skip the event attendance requirement.
</p>
</div>
</Card>
{/* Volunteer Interests */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests (Optional)
</h3>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
I may at some time be interested in volunteering with LOAF in the following ways (training is provided)
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{volunteerOptions.map((option) => (
<div key={option} className="flex items-center space-x-2">
<Checkbox
id={`volunteer_${option}`}
checked={formData.volunteer_interests.includes(option)}
onCheckedChange={() => handleVolunteerChange(option)}
/>
<Label htmlFor={`volunteer_${option}`} className="text-base cursor-pointer">
{option}
</Label>
</div>
))}
</div>
</Card>
{/* Scholarship Request */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center space-x-2">
<Checkbox
id="scholarship_requested"
checked={formData.scholarship_requested}
onCheckedChange={(checked) => handleCheckboxChange('scholarship_requested', checked)}
/>
<Label htmlFor="scholarship_requested" className="text-base cursor-pointer font-semibold">
I am requesting for scholarship
</Label>
</div>
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Scholarship information is kept confidential
</p>
{formData.scholarship_requested && (
<div className="mt-4">
<Label htmlFor="scholarship_reason">Please explain your situation *</Label>
<Textarea
id="scholarship_reason"
name="scholarship_reason"
value={formData.scholarship_reason}
onChange={handleInputChange}
placeholder="Tell us why you're requesting a scholarship..."
rows={4}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
)}
</Card>
</>
)}
{/* Additional Information (if available) */} {/* Additional Information (if available) */}
{(user.address || user.city || user.state || user.zip_code) && ( {(user.address || user.city || user.state || user.zip_code) && (
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl"> <Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
@@ -156,6 +510,19 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
</div> </div>
<DialogFooter> <DialogFooter>
<div className="flex-1 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{isSaving && 'Saving changes...'}
{!isSaving && hasUnsavedChanges && 'Unsaved changes'}
{!isSaving && !hasUnsavedChanges && 'All changes saved'}
</div>
<Button
type="button"
onClick={() => saveProfile(true)}
disabled={!hasUnsavedChanges || isSaving}
className="rounded-xl border-2 border-[var(--neutral-800)] bg-white text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]"
>
Save All
</Button>
<Button <Button
type="button" type="button"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import api from '../utils/api'; import api from '../utils/api';
import { Card } from '../components/ui/card'; import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
@@ -9,11 +8,11 @@ import { Label } from '../components/ui/label';
import { Textarea } from '../components/ui/textarea'; import { Textarea } from '../components/ui/textarea';
import { toast } from 'sonner'; import { toast } from 'sonner';
import Navbar from '../components/Navbar'; import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter'; import { User, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2, Eye, CreditCard, Handshake, ArrowLeft } from 'lucide-react';
import { User, ArrowLeft, Save, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2 } 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 TransactionHistory from '../components/TransactionHistory'; import PaymentMethodsSection from '../components/PaymentMethodsSection';
import { useNavigate } from 'react-router-dom';
const Profile = () => { const Profile = () => {
const { user } = useAuth(); const { user } = useAuth();
@@ -24,13 +23,13 @@ const Profile = () => {
const [previewImage, setPreviewImage] = useState(null); const [previewImage, setPreviewImage] = useState(null);
const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); // Default 50MB const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); // Default 50MB in bytes const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] }); const [activeTab, setActiveTab] = useState('account');
const [transactionsLoading, setTransactionsLoading] = useState(true); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [initialFormData, setInitialFormData] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
// Personal Information
first_name: '', first_name: '',
last_name: '', last_name: '',
phone: '', phone: '',
@@ -38,19 +37,15 @@ const Profile = () => {
city: '', city: '',
state: '', state: '',
zipcode: '', zipcode: '',
// Partner Information
partner_first_name: '', partner_first_name: '',
partner_last_name: '', partner_last_name: '',
partner_is_member: false, partner_is_member: false,
partner_plan_to_become_member: false, partner_plan_to_become_member: false,
// Newsletter Preferences
newsletter_publish_name: false, newsletter_publish_name: false,
newsletter_publish_photo: false, newsletter_publish_photo: false,
newsletter_publish_birthday: false, newsletter_publish_birthday: false,
newsletter_publish_none: false, newsletter_publish_none: false,
// Volunteer Interests (array)
volunteer_interests: [], volunteer_interests: [],
// Member Directory Settings
show_in_directory: false, show_in_directory: false,
directory_email: '', directory_email: '',
directory_bio: '', directory_bio: '',
@@ -63,9 +58,16 @@ const Profile = () => {
useEffect(() => { useEffect(() => {
fetchConfig(); fetchConfig();
fetchProfile(); fetchProfile();
fetchTransactions();
}, []); }, []);
// Track unsaved changes
useEffect(() => {
if (initialFormData) {
const hasChanges = JSON.stringify(formData) !== JSON.stringify(initialFormData);
setHasUnsavedChanges(hasChanges);
}
}, [formData, initialFormData]);
const fetchConfig = async () => { const fetchConfig = async () => {
try { try {
const response = await api.get('/config'); const response = await api.get('/config');
@@ -73,7 +75,6 @@ const Profile = () => {
setMaxFileSizeBytes(response.data.max_file_size_bytes); setMaxFileSizeBytes(response.data.max_file_size_bytes);
} catch (error) { } catch (error) {
console.error('Failed to fetch config, using defaults:', error); console.error('Failed to fetch config, using defaults:', error);
// Keep default values if fetch fails
} }
}; };
@@ -83,8 +84,7 @@ const Profile = () => {
setProfileData(response.data); setProfileData(response.data);
setProfilePhotoUrl(response.data.profile_photo_url); setProfilePhotoUrl(response.data.profile_photo_url);
setPreviewImage(response.data.profile_photo_url); setPreviewImage(response.data.profile_photo_url);
setFormData({ const newFormData = {
// Personal Information
first_name: response.data.first_name || '', first_name: response.data.first_name || '',
last_name: response.data.last_name || '', last_name: response.data.last_name || '',
phone: response.data.phone || '', phone: response.data.phone || '',
@@ -92,19 +92,15 @@ const Profile = () => {
city: response.data.city || '', city: response.data.city || '',
state: response.data.state || '', state: response.data.state || '',
zipcode: response.data.zipcode || '', zipcode: response.data.zipcode || '',
// Partner Information
partner_first_name: response.data.partner_first_name || '', partner_first_name: response.data.partner_first_name || '',
partner_last_name: response.data.partner_last_name || '', partner_last_name: response.data.partner_last_name || '',
partner_is_member: response.data.partner_is_member || false, partner_is_member: response.data.partner_is_member || false,
partner_plan_to_become_member: response.data.partner_plan_to_become_member || false, partner_plan_to_become_member: response.data.partner_plan_to_become_member || false,
// Newsletter Preferences
newsletter_publish_name: response.data.newsletter_publish_name || false, newsletter_publish_name: response.data.newsletter_publish_name || false,
newsletter_publish_photo: response.data.newsletter_publish_photo || false, newsletter_publish_photo: response.data.newsletter_publish_photo || false,
newsletter_publish_birthday: response.data.newsletter_publish_birthday || false, newsletter_publish_birthday: response.data.newsletter_publish_birthday || false,
newsletter_publish_none: response.data.newsletter_publish_none || false, newsletter_publish_none: response.data.newsletter_publish_none || false,
// Volunteer Interests
volunteer_interests: response.data.volunteer_interests || [], volunteer_interests: response.data.volunteer_interests || [],
// Member Directory Settings
show_in_directory: response.data.show_in_directory || false, show_in_directory: response.data.show_in_directory || false,
directory_email: response.data.directory_email || '', directory_email: response.data.directory_email || '',
directory_bio: response.data.directory_bio || '', directory_bio: response.data.directory_bio || '',
@@ -112,25 +108,14 @@ const Profile = () => {
directory_phone: response.data.directory_phone || '', directory_phone: response.data.directory_phone || '',
directory_dob: response.data.directory_dob || '', directory_dob: response.data.directory_dob || '',
directory_partner_name: response.data.directory_partner_name || '' directory_partner_name: response.data.directory_partner_name || ''
}); };
setFormData(newFormData);
setInitialFormData(newFormData);
} catch (error) { } catch (error) {
toast.error('Failed to load profile'); toast.error('Failed to load profile');
} }
}; };
const fetchTransactions = async () => {
try {
setTransactionsLoading(true);
const response = await api.get('/members/transactions');
setTransactions(response.data);
} catch (error) {
console.error('Failed to load transactions:', error);
// Don't show error toast - transactions are optional
} finally {
setTransactionsLoading(false);
}
};
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 }));
@@ -150,7 +135,6 @@ const Profile = () => {
})); }));
}; };
// Volunteer interest options
const volunteerOptions = [ const volunteerOptions = [
'Event Planning', 'Event Planning',
'Social Media', 'Social Media',
@@ -168,13 +152,11 @@ const Profile = () => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('Please select an image file'); toast.error('Please select an image file');
return; return;
} }
// Validate file size
if (file.size > maxFileSizeBytes) { if (file.size > maxFileSizeBytes) {
toast.error(`File size must be less than ${maxFileSizeMB}MB`); toast.error(`File size must be less than ${maxFileSizeMB}MB`);
return; return;
@@ -222,6 +204,8 @@ const Profile = () => {
try { try {
await api.put('/users/profile', formData); await api.put('/users/profile', formData);
toast.success('Profile updated successfully!'); toast.success('Profile updated successfully!');
setInitialFormData(formData);
setHasUnsavedChanges(false);
fetchProfile(); fetchProfile();
} catch (error) { } catch (error) {
toast.error('Failed to update profile'); toast.error('Failed to update profile');
@@ -230,91 +214,82 @@ const Profile = () => {
} }
}; };
const tabs = [
{ id: 'account', label: 'Account & Privacy', shortLabel: 'Account', icon: Lock },
{ id: 'bio', label: 'My Bio & Directory', shortLabel: 'Bio & Directory', icon: User },
{ id: 'engagement', label: 'Engagement', shortLabel: 'Engagement', icon: Handshake }
];
if (!profileData) { if (!profileData) {
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white dark:bg-[var(--purple-deep)]">
<Navbar /> <Navbar />
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p> <p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p>
</div> </div>
</div> </div>
); );
} }
return ( // Account & Privacy Tab Content
<div className="min-h-screen bg-white"> const AccountPrivacyContent = () => (
<Navbar /> <div className="space-y-6 ">
<div className="max-w-4xl mx-auto px-6 py-12"> <Card className="space-y-6 px-6 pb-6">
<div className="mb-8 flex justify-between"> <div className="bg-brand-purple text-white px-4 py-3 rounded-t-xl -mx-6 -mt-6 mb-6">
<div classname=""> <h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Account & Privacy</h3>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
My Profile
</h1>
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update your personal information below.
</p>
</div> </div>
{/* Todo: functional back button */} <div>
<Button <p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Login Email</p>
onClick={() => navigate(-1)} <p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p>
className="h-fit bg-brand-purple hover:bg-brand-purple/80">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</div> </div>
<Card className="p-8 bg-white rounded-2xl border border-[var(--neutral-800)] shadow-lg"> <div className="flex items-center justify-between">
{/* Read-only Information */}
<div className="mb-8 pb-8 border-b border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<User className="h-6 w-6 text-brand-purple " />
Account Information
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p> <p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Password</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p> <p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}></p>
</div> </div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Status</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.status.replace('_', ' ')}</p>
</div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.role}</p>
</div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(profileData.date_of_birth).toLocaleDateString()}
</p>
</div>
</div>
<div className="mt-6">
<Button <Button
type="button" type="button"
onClick={() => setPasswordDialogOpen(true)} onClick={() => setPasswordDialogOpen(true)}
variant="outline" variant="outline"
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6 py-3" className="border-2 border-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg px-4 py-2"
> >
<Lock className="h-4 w-4 mr-2" /> Change
Change Password
</Button> </Button>
</div> </div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Status</p>
<p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{profileData.status?.replace('_', ' ') || 'Active'}
</p>
</div>
</Card>
{/* Payment Methods Section */}
<PaymentMethodsSection />
</div>
);
// My Bio & Directory Tab Content
const BioDirectoryContent = () => (
<Card className="space-y-6 px-6 pb-6">
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-6 mb-6">
<h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>My Bio & Directory</h3>
</div> </div>
{/* Profile Photo Section */} {/* Profile Photo Section */}
<div className="pb-8 mb-8 border-b border-[var(--neutral-800)]"> <div className="pb-6 border-b border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Camera className="h-6 w-6 text-brand-purple " /> <Camera className="h-5 w-5 text-brand-purple" />
Profile Photo Profile Photo
</h2> </h4>
<div className="flex flex-col md:flex-row items-center gap-6"> <div className="flex flex-col md:flex-row items-center gap-6">
<Avatar className="h-32 w-32 border-4 border-[var(--neutral-800)]"> <Avatar className="h-24 w-24 border-4 border-[var(--neutral-800)]">
<AvatarImage src={previewImage} alt="Profile" /> <AvatarImage src={previewImage} alt="Profile" />
<AvatarFallback className="bg-[var(--lavender-300)] text-brand-purple text-3xl"> <AvatarFallback className="bg-[var(--lavender-300)] text-brand-purple text-2xl">
{profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)} {profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -332,7 +307,7 @@ const Profile = () => {
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploadingPhoto} disabled={uploadingPhoto}
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6 py-3" className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-4 py-2"
> >
<Upload className="h-4 w-4 mr-2" /> <Upload className="h-4 w-4 mr-2" />
{uploadingPhoto ? 'Uploading...' : 'Upload Photo'} {uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
@@ -344,27 +319,27 @@ const Profile = () => {
onClick={handlePhotoDelete} onClick={handlePhotoDelete}
disabled={uploadingPhoto} disabled={uploadingPhoto}
variant="outline" variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-6 py-3" className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-4 py-2"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete Photo Delete Photo
</Button> </Button>
)} )}
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload a profile photo (Max {maxFileSizeMB}MB) Max {maxFileSizeMB}MB
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Editable Form */} {/* Personal Information */}
<form onSubmit={handleSubmit} className="space-y-6" data-testid="profile-form"> <div className="space-y-4">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Personal Information Personal Information
</h2> </h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="first_name">First Name</Label> <Label htmlFor="first_name">First Name</Label>
<Input <Input
@@ -372,7 +347,7 @@ const Profile = () => {
name="first_name" name="first_name"
value={formData.first_name} value={formData.first_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
data-testid="first-name-input" data-testid="first-name-input"
/> />
</div> </div>
@@ -383,7 +358,7 @@ const Profile = () => {
name="last_name" name="last_name"
value={formData.last_name} value={formData.last_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
data-testid="last-name-input" data-testid="last-name-input"
/> />
</div> </div>
@@ -397,7 +372,7 @@ const Profile = () => {
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
data-testid="phone-input" data-testid="phone-input"
/> />
</div> </div>
@@ -409,12 +384,12 @@ const Profile = () => {
name="address" name="address"
value={formData.address} value={formData.address}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
data-testid="address-input" data-testid="address-input"
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
<Label htmlFor="city">City</Label> <Label htmlFor="city">City</Label>
<Input <Input
@@ -422,7 +397,7 @@ const Profile = () => {
name="city" name="city"
value={formData.city} value={formData.city}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
data-testid="city-input" data-testid="city-input"
/> />
</div> </div>
@@ -433,7 +408,7 @@ const Profile = () => {
name="state" name="state"
value={formData.state} value={formData.state}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
data-testid="state-input" data-testid="state-input"
/> />
</div> </div>
@@ -444,20 +419,132 @@ const Profile = () => {
name="zipcode" name="zipcode"
value={formData.zipcode} value={formData.zipcode}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
data-testid="zipcode-input" data-testid="zipcode-input"
/> />
</div> </div>
</div> </div>
</div>
{/* Section 2: Partner Information */} {/* Member Directory Settings */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]"> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 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" }}>
<Heart className="h-6 w-6 text-[var(--orange-light)]" /> <BookUser className="h-5 w-5 text-[var(--orange-light)]" />
Member Directory Settings
</h4>
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Control your visibility and information in the member directory.
</p>
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg">
<input
type="checkbox"
id="show_in_directory"
name="show_in_directory"
checked={formData.show_in_directory}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium">
Include me in the member directory
</Label>
</div>
{formData.show_in_directory && (
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
<div>
<Label htmlFor="directory_email">Directory Email</Label>
<Input
id="directory_email"
name="directory_email"
type="email"
value={formData.directory_email}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - email to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_bio">Bio</Label>
<Textarea
id="directory_bio"
name="directory_bio"
value={formData.directory_bio}
onChange={handleInputChange}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
placeholder="Tell other members about yourself..."
/>
</div>
<div>
<Label htmlFor="directory_address">Address</Label>
<Input
id="directory_address"
name="directory_address"
value={formData.directory_address}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - address to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_phone">Phone</Label>
<Input
id="directory_phone"
name="directory_phone"
type="tel"
value={formData.directory_phone}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - phone to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_dob">Date of Birth</Label>
<Input
id="directory_dob"
name="directory_dob"
type="date"
value={formData.directory_dob}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
<div>
<Label htmlFor="directory_partner_name">Partner Name</Label>
<Input
id="directory_partner_name"
name="directory_partner_name"
value={formData.directory_partner_name}
onChange={handleInputChange}
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - partner name to show in directory"
/>
</div>
</div>
)}
</div>
</Card>
);
// Engagement Tab Content
const EngagementContent = () => (
<Card className="space-y-6 px-6 pb-6">
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-6 mb-6">
<h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Engagement</h3>
</div>
{/* Partner Information */}
<div className="space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
Partner Information Partner Information
</h2> </h4>
<div className="space-y-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<Label htmlFor="partner_first_name">Partner First Name</Label> <Label htmlFor="partner_first_name">Partner First Name</Label>
<Input <Input
@@ -465,7 +552,7 @@ const Profile = () => {
name="partner_first_name" name="partner_first_name"
value={formData.partner_first_name} value={formData.partner_first_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
@@ -476,7 +563,7 @@ const Profile = () => {
name="partner_last_name" name="partner_last_name"
value={formData.partner_last_name} value={formData.partner_last_name}
onChange={handleInputChange} onChange={handleInputChange}
className="h-14 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"
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
@@ -488,11 +575,10 @@ const Profile = () => {
id="partner_is_member" id="partner_is_member"
name="partner_is_member" name="partner_is_member"
checked={formData.partner_is_member} checked={formData.partner_is_member}
accent-color="var(--brand-white)"
onChange={handleCheckboxChange} onChange={handleCheckboxChange}
className="ui-checkbox " className="ui-checkbox"
/> />
<Label htmlFor="partner_is_member" className="cursor-pointer text-[var(--purple-ink)] "> <Label htmlFor="partner_is_member" className="cursor-pointer text-[var(--purple-ink)]">
My partner is a current member My partner is a current member
</Label> </Label>
</div> </div>
@@ -503,7 +589,7 @@ const Profile = () => {
name="partner_plan_to_become_member" name="partner_plan_to_become_member"
checked={formData.partner_plan_to_become_member} checked={formData.partner_plan_to_become_member}
onChange={handleCheckboxChange} onChange={handleCheckboxChange}
className="ui-checkbox " className="ui-checkbox"
/> />
<Label htmlFor="partner_plan_to_become_member" className="cursor-pointer text-[var(--purple-ink)]"> <Label htmlFor="partner_plan_to_become_member" className="cursor-pointer text-[var(--purple-ink)]">
My partner plans to become a member My partner plans to become a member
@@ -511,15 +597,14 @@ const Profile = () => {
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Section 3: Newsletter Preferences */} {/* Newsletter Preferences */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]"> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 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" }}>
<Mail className="h-6 w-6 text-[var(--green-light)]" /> <Mail className="h-5 w-5 text-[var(--green-light)]" />
Newsletter Preferences Newsletter Preferences
</h2> </h4>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Choose what information you'd like published in our member newsletter. Choose what information you'd like published in our member newsletter.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
@@ -530,7 +615,7 @@ const Profile = () => {
name="newsletter_publish_name" name="newsletter_publish_name"
checked={formData.newsletter_publish_name} checked={formData.newsletter_publish_name}
onChange={handleCheckboxChange} onChange={handleCheckboxChange}
className="ui-checkbox " className="ui-checkbox"
/> />
<Label htmlFor="newsletter_publish_name" className="cursor-pointer text-[var(--purple-ink)]"> <Label htmlFor="newsletter_publish_name" className="cursor-pointer text-[var(--purple-ink)]">
Publish my name Publish my name
@@ -578,13 +663,13 @@ const Profile = () => {
</div> </div>
</div> </div>
{/* Section 4: Volunteer Interests */} {/* Volunteer Interests */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]"> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 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-6 w-6 text-brand-purple " /> <Users className="h-5 w-5 text-brand-purple" />
Volunteer Interests Volunteer Interests
</h2> </h4>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Select areas where you'd like to volunteer and help our community. Select areas where you'd like to volunteer and help our community.
</p> </p>
<div className="grid md:grid-cols-2 gap-3"> <div className="grid md:grid-cols-2 gap-3">
@@ -595,7 +680,7 @@ const Profile = () => {
id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`} id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
checked={formData.volunteer_interests.includes(option)} checked={formData.volunteer_interests.includes(option)}
onChange={() => handleVolunteerToggle(option)} onChange={() => handleVolunteerToggle(option)}
className="ui-checkbox " className="ui-checkbox"
/> />
<Label <Label
htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`} htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
@@ -607,133 +692,133 @@ const Profile = () => {
))} ))}
</div> </div>
</div> </div>
</Card>
);
{/* Section 5: Member Directory Settings */} return (
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]"> <div className="min-h-screen bg-background flex flex-col">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <Navbar />
<BookUser className="h-6 w-6 text-[var(--orange-light)]" />
Member Directory Settings
</h2>
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Control your visibility and information in the member directory.
</p>
<div className="space-y-6"> <div className="flex-1 flex flex-col">
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg"> <div className="max-w-5xl mx-auto px-4 sm:px-6 py-8 w-full flex-1 pb-24">
<input {/* Header */}
type="checkbox" <div className="flex items-center justify-between mb-6">
id="show_in_directory" <div className='space-y-4'>
name="show_in_directory"
checked={formData.show_in_directory} <h1 className="text-4xl md:text-4xl font-semibold " style={{ fontFamily: "'Inter', sans-serif" }}>
onChange={handleCheckboxChange} My Profile
className="ui-checkbox" </h1>
/> <p className='text-brand-purple text-md'>Update your personal information below.</p>
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium"> </div>
Include me in the member directory {/* <Button
</Label> type="button"
variant="outline"
className="border-2 hover:bg-white/10 rounded-lg px-4 py-2"
>
<Eye className="h-4 w-4 mr-2 md:mr-2" />
<span className="hidden md:inline">Public Profile Preview</span>
<span className="md:hidden">Preview</span>
</Button> */}
</div> </div>
{formData.show_in_directory && ( {/* Main Content Div */}
<div className="space-y-6 pl-4 border-l-4 border-[var(--neutral-800)]"> <div className="overflow-hidden ">
<div> <form onSubmit={handleSubmit} data-testid="profile-form">
<Label htmlFor="directory_email">Directory Email</Label> {/* Mobile Tabs */}
<Input <div className="md:hidden flex border-b border-[var(--neutral-800)] mb-4 gap-1 ">
id="directory_email" {tabs.map((tab) => {
name="directory_email" const IconComponent = tab.icon;
type="email" return (
value={formData.directory_email} <button
onChange={handleInputChange} key={tab.id}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " type="button"
placeholder="Optional - email to show in directory" onClick={() => setActiveTab(tab.id)}
/> className={`flex-1 flex flex-col items-center rounded-xl gap-1 px-3 py-3 text-xs font-medium transition-colors ${activeTab === tab.id
? 'bg-brand-purple text-white'
: 'text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]'
}`}
>
<IconComponent className="h-5 w-5" />
<span className="whitespace-nowrap">{tab.shortLabel}</span>
</button>
);
})}
</div> </div>
<div> {/* Desktop Layout */}
<Label htmlFor="directory_bio">Bio</Label> <div className="flex">
<Textarea {/* Desktop Sidebar Tabs */}
id="directory_bio" <div className="hidden md:flex flex-col w-64 border-[var(--neutral-800)] mr-4 gap-2">
name="directory_bio" {tabs.map((tab) => {
value={formData.directory_bio} const IconComponent = tab.icon;
onChange={handleInputChange} return (
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]" <button
placeholder="Tell other members about yourself..." key={tab.id}
/> type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-left font-medium transition-colors ${activeTab === tab.id
? 'bg-brand-purple text-white'
: 'text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]'
}`}
>
<IconComponent className="h-5 w-5" />
<span>{tab.label}</span>
</button>
);
})}
</div> </div>
<div> {/* Content Area */}
<Label htmlFor="directory_address">Address</Label> <div className="flex-1 p-6 min-h-[500px]">
<Input {activeTab === 'account' && <AccountPrivacyContent />}
id="directory_address" {activeTab === 'bio' && <BioDirectoryContent />}
name="directory_address" {activeTab === 'engagement' && <EngagementContent />}
value={formData.directory_address} </div>
onChange={handleInputChange} </div>
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " </form>
placeholder="Optional - address to show in directory" </div>
/>
</div> </div>
<div> {/* Sticky Footer */}
<Label htmlFor="directory_phone">Phone</Label> <div className="fixed bottom-0 left-0 right-0 bg-white border-t border-[var(--neutral-800)] px-4 sm:px-6 py-4 z-50">
<Input <div className="max-w-5xl px-6 mx-auto flex items-center justify-between">
id="directory_phone"
name="directory_phone"
type="tel"
value={formData.directory_phone}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Optional - phone to show in directory"
/>
</div>
<div> <div className='flex gap-2 w-full lg:justify-between md:mr-5'>
<Label htmlFor="directory_dob">Date of Birth</Label> <Button
<Input onClick={() => navigate(-1)}
id="directory_dob" className="h-fit bg-brand-purple hover:bg-brand-purple/80 rounded-lg px-6 py-2 font-medium shadow-lg w-full md:w-auto">
name="directory_dob" <ArrowLeft className="h-4 w-4 mr-2" />
type="date" Back
value={formData.directory_dob} </Button>
onChange={handleInputChange} <div className="flex items-center gap-2">
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " {hasUnsavedChanges && (
/> <>
</div> <span className="h-3 w-3 rounded-full bg-[var(--orange-light)]"></span>
<span className="text-sm text-[var(--purple-ink)] " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div> Unsaved changes
<Label htmlFor="directory_partner_name">Partner Name</Label> </span>
<Input </>
id="directory_partner_name"
name="directory_partner_name"
value={formData.directory_partner_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Optional - partner name to show in directory"
/>
</div>
</div>
)} )}
</div> </div>
</div>
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<Button <Button
type="submit" type="button"
onClick={handleSubmit}
disabled={loading} disabled={loading}
className="bg-brand-purple text-white hover:bg-brand-dark-lavender rounded-full px-8 py-6 text-lg font-medium shadow-lg disabled:opacity-50" className="bg-brand-purple text-white hover:bg-brand-dark-lavender rounded-lg px-6 py-2 font-medium shadow-lg disabled:opacity-50 w-full md:w-auto"
data-testid="save-profile-button" data-testid="save-profile-button"
> >
<Save className="h-5 w-5 mr-2" />
{loading ? 'Saving...' : 'Save Changes'} {loading ? 'Saving...' : 'Save Changes'}
</Button> </Button>
</div> </div>
</form>
</Card> </div>
</div>
</div>
<ChangePasswordDialog <ChangePasswordDialog
open={passwordDialogOpen} open={passwordDialogOpen}
onOpenChange={setPasswordDialogOpen} onOpenChange={setPasswordDialogOpen}
/> />
</div>
<MemberFooter />
</div> </div>
); );
}; };

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { 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}
@@ -267,16 +286,28 @@ const Register = () => {
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-backgroundrounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed" className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="submit-register-button" 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

View File

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

View File

@@ -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}>

File diff suppressed because it is too large Load Diff

View File

@@ -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" }}>

View File

@@ -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>

View File

@@ -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';
@@ -135,7 +136,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">
@@ -154,7 +155,7 @@ const MembersDirectory = () => {
</div> </div>
{/* Search Bar */} {/* Search Bar */}
<div className="mb-24 mx-10"> <div className="mb-24 w-full">
<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
@@ -221,9 +222,10 @@ const MembersDirectory = () => {
</h3> </h3>
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '} 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>