33 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
d5152609b6 Donation status badge upates, admin validation tootips 2026-01-29 19:37:41 -06:00
kayela
de719d9d69 refactor subscriptionsTable.jsx 2026-01-29 18:46:16 -06:00
kayela
27d5c48805 Componentized subscription table 2026-01-29 18:36:13 -06:00
kayela
64d631d890 Members -removed un used selection options. Profile added back button 2026-01-29 18:12:48 -06:00
kayela
4423576fa2 Merge branch 'theme-provider' into dev 2026-01-29 00:01:43 -06:00
kayela
a77fbc47e3 styling improvements 2026-01-28 19:08:13 -06:00
kayela
d638afcdb2 add column for email expiry date
Members > Invite member says invite Staff in dialog
 resend email button
 Update form member form to say member and not staff
 review application function
 manual payment functionality
 basic implementation of theme
 actions dropdown
2026-01-28 18:59:19 -06:00
kayela
a247ac5219 feat: added theme success and warning colors
fix: invite member dialog box
feat: email expiry date column
feat: resend email verification button
fix: select item text can be seen
2026-01-28 15:03:46 -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
kayela
01722edad9 updated badge glitch 2026-01-27 17:30:50 -06:00
kayela
378b909398 removed transaction history from Profile.js 2026-01-27 16:38:21 -06:00
kayela
4ad1997bd5 Member tiers implementation intact. Icons updated to be Lucide React. Create/edit member tiers. Display member badge. Transaction history now in My profile dashboard. Adjusted Icons for badges. Added badges on my profile page 2026-01-27 16:30:26 -06:00
kayela
0d7e3a1286 tweaked statcard for better styling when digits are greater than 2 2026-01-27 15:19:19 -06:00
kayela
0c3d4a4edd Updated stat cards to be consistent with rest of codebase 2026-01-27 15:11:25 -06:00
kayela
97aa7860a9 feat: integrate TransactionHistory component into Dashboard and update styles for better UI consistency 2026-01-27 14:33:36 -06:00
Koncept Kit
467f34b42a - - New ThemeConfigContext provider that fetches theme on app load and applies it to the DOM (title, meta description, favicon, CSS variables,
theme-color)/- - Admin Theme settings page under Settings > Theme tab/- All logo references (5 components) now pull from the theme config with fallback to default
2026-01-27 21:32:22 +07:00
Koncept Kit
85070cf77b Update Footer to get current year 2026-01-27 16:44:56 +07:00
9dcb8e3185 Merge pull request 'theme-provider' (#20) from theme-provider into dev
Reviewed-on: #20
2026-01-27 08:41:49 +00:00
kayela
a88388ed5d Merge branch 'dev' into theme-provider 2026-01-27 00:53:27 -06:00
Koncept Kit
002ef5c897 Fixes 2026-01-24 23:56:15 +07:00
7d0c207f1b Merge pull request 'theme-provider' (#18) from theme-provider into dev
Reviewed-on: #18
2026-01-21 04:58:57 +00:00
58 changed files with 8994 additions and 1995 deletions

75
.dockerignore Normal file
View File

@@ -0,0 +1,75 @@
# Git
.git
.gitignore
# Dependencies
node_modules/
# Build output (we build inside Docker)
build/
dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Testing
coverage/
.nyc_output/
# Environment files (will be passed as build args)
.env
.env.local
.env.development
.env.development.local
.env.test
.env.test.local
.env.production
.env.production.local
*.env
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Docker
Dockerfile
docker-compose*.yml
.docker/
# Documentation
*.md
docs/
# OS files
.DS_Store
Thumbs.db
# Temporary files
tmp/
temp/
*.tmp
# ESLint cache
.eslintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Yarn
.yarn-integrity
.pnp.*
# Storybook
storybook-static/
# Design files (if any)
.superdesign/

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Frontend Dockerfile - React with multi-stage build
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json yarn.lock ./
# Install dependencies
RUN yarn install --frozen-lockfile
# Copy source code
COPY . .
# Build arguments for environment variables
ARG REACT_APP_BACKEND_URL
ENV REACT_APP_BACKEND_URL=$REACT_APP_BACKEND_URL
# Build the application
RUN yarn build
# Stage 2: Production with Nginx
FROM nginx:alpine AS production
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /app/build /usr/share/nginx/html
# Create non-root user for security
RUN adduser -D -g '' appuser && \
chown -R appuser:appuser /usr/share/nginx/html && \
chown -R appuser:appuser /var/cache/nginx && \
chown -R appuser:appuser /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R appuser:appuser /var/run/nginx.pid
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

44
nginx.conf Normal file
View File

@@ -0,0 +1,44 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Handle React Router - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Disable access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@@ -25,6 +25,7 @@ import AdminPermissions from './pages/admin/AdminPermissions';
import AdminSettings from './pages/admin/AdminSettings'; import AdminSettings from './pages/admin/AdminSettings';
import AdminMemberTiers from './pages/admin/AdminMemberTiers'; import AdminMemberTiers from './pages/admin/AdminMemberTiers';
import AdminRoles from './pages/admin/AdminRoles'; import AdminRoles from './pages/admin/AdminRoles';
import AdminTheme from './pages/admin/AdminTheme';
import AdminEvents from './pages/admin/AdminEvents'; import AdminEvents from './pages/admin/AdminEvents';
import AdminEventAttendance from './pages/admin/AdminEventAttendance'; import AdminEventAttendance from './pages/admin/AdminEventAttendance';
import AdminValidations from './pages/admin/AdminValidations'; import AdminValidations from './pages/admin/AdminValidations';
@@ -46,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';
@@ -237,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>
@@ -291,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>
@@ -301,7 +318,7 @@ 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> </Route>
{/* 404 - Catch all undefined routes */} {/* 404 - Catch all undefined routes */}

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

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import api from '../utils/api'; import api from '../utils/api';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { import {
@@ -26,12 +27,15 @@ 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 }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);
const [storageUsed, setStorageUsed] = useState(0); const [storageUsed, setStorageUsed] = useState(0);
@@ -102,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,
@@ -281,7 +298,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]"> <div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]">
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0"> <Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
<img <img
src={`${process.env.PUBLIC_URL}/loaf-logo.png`} src={getLogoUrl()}
alt="LOAF Logo" alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8' className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`} }`}
@@ -314,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">
@@ -323,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,576 @@
import React, { useState, useEffect } from 'react';
import api from '../utils/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
import { Card } from './ui/card';
import { toast } from 'sonner';
import { Loader2, Repeat, Search, Calendar, Heart, X, User } from 'lucide-react';
const CreateSubscriptionDialog = ({ open, onOpenChange, onSuccess }) => {
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [searchLoading, setSearchLoading] = useState(false);
const [allUsers, setAllUsers] = useState([]);
// Plan state
const [plans, setPlans] = useState([]);
const [selectedPlan, setSelectedPlan] = useState(null);
const [useCustomPeriod, setUseCustomPeriod] = useState(false);
// Form state
const [formData, setFormData] = useState({
plan_id: '',
amount: '',
payment_date: new Date().toISOString().split('T')[0],
payment_method: 'cash',
custom_period_start: new Date().toISOString().split('T')[0],
custom_period_end: '',
notes: ''
});
const [loading, setLoading] = useState(false);
// Fetch users and plans when dialog opens
useEffect(() => {
const fetchData = async () => {
if (!open) return;
try {
const [usersResponse, plansResponse] = await Promise.all([
api.get('/admin/users'),
api.get('/admin/subscriptions/plans')
]);
setAllUsers(usersResponse.data);
setPlans(plansResponse.data.filter(p => p.active));
} catch (error) {
toast.error('Failed to load data');
}
};
fetchData();
}, [open]);
// Filter users based on search query
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
setSearchLoading(true);
const query = searchQuery.toLowerCase();
const filtered = allUsers.filter(user =>
user.first_name?.toLowerCase().includes(query) ||
user.last_name?.toLowerCase().includes(query) ||
user.email?.toLowerCase().includes(query)
).slice(0, 10); // Limit to 10 results
setSearchResults(filtered);
setSearchLoading(false);
}, [searchQuery, allUsers]);
// Update amount when plan changes
useEffect(() => {
if (selectedPlan && !formData.amount) {
const suggestedAmount = (selectedPlan.suggested_price_cents || selectedPlan.minimum_price_cents || selectedPlan.price_cents) / 100;
setFormData(prev => ({
...prev,
amount: suggestedAmount.toFixed(2)
}));
}
}, [selectedPlan]);
// Calculate donation breakdown
const getAmountBreakdown = () => {
if (!selectedPlan || !formData.amount) return null;
const totalCents = Math.round(parseFloat(formData.amount) * 100);
const minimumCents = selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000;
const donationCents = Math.max(0, totalCents - minimumCents);
return {
total: totalCents,
base: minimumCents,
donation: donationCents
};
};
const formatPrice = (cents) => {
return `$${(cents / 100).toFixed(2)}`;
};
const breakdown = getAmountBreakdown();
const handleSelectUser = (user) => {
setSelectedUser(user);
setSearchQuery('');
setSearchResults([]);
};
const handleClearUser = () => {
setSelectedUser(null);
setFormData({
plan_id: '',
amount: '',
payment_date: new Date().toISOString().split('T')[0],
payment_method: 'cash',
custom_period_start: new Date().toISOString().split('T')[0],
custom_period_end: '',
notes: ''
});
setSelectedPlan(null);
setUseCustomPeriod(false);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!selectedUser) {
toast.error('Please select a user');
return;
}
if (!formData.plan_id) {
toast.error('Please select a subscription plan');
return;
}
if (!formData.amount || parseFloat(formData.amount) <= 0) {
toast.error('Please enter a valid payment amount');
return;
}
// Validate minimum amount
const amountCents = Math.round(parseFloat(formData.amount) * 100);
const minimumCents = selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000;
if (amountCents < minimumCents) {
toast.error(`Amount must be at least ${formatPrice(minimumCents)}`);
return;
}
if (useCustomPeriod && (!formData.custom_period_start || !formData.custom_period_end)) {
toast.error('Please specify both start and end dates for custom period');
return;
}
setLoading(true);
try {
const payload = {
plan_id: formData.plan_id,
amount_cents: amountCents,
payment_date: new Date(formData.payment_date).toISOString(),
payment_method: formData.payment_method,
override_plan_dates: useCustomPeriod,
notes: formData.notes || null
};
if (useCustomPeriod) {
payload.custom_period_start = new Date(formData.custom_period_start).toISOString();
payload.custom_period_end = new Date(formData.custom_period_end).toISOString();
}
await api.post(`/admin/users/${selectedUser.id}/activate-payment`, payload);
toast.success(`Subscription created for ${selectedUser.first_name} ${selectedUser.last_name}!`);
// Reset form
handleClearUser();
onOpenChange(false);
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to create subscription';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const handleClose = () => {
handleClearUser();
setSearchQuery('');
setSearchResults([]);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Repeat className="h-6 w-6" />
Create Subscription
</DialogTitle>
<DialogDescription className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Search for an existing member and create a subscription with manual payment processing.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* User Search Section */}
{!selectedUser ? (
<div className="space-y-3">
<Label className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Search Member
</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple" />
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
{searchLoading && (
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-brand-purple" />
)}
</div>
{/* Search Results */}
{searchResults.length > 0 && (
<Card className="border-2 border-[var(--neutral-800)] rounded-xl overflow-hidden">
<div className="max-h-60 overflow-y-auto">
{searchResults.map((user) => (
<button
key={user.id}
type="button"
onClick={() => handleSelectUser(user)}
className="w-full p-3 text-left hover:bg-[var(--lavender-400)] transition-colors border-b border-[var(--neutral-800)] last:border-b-0"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
<User className="h-5 w-5 text-brand-purple" />
</div>
<div>
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.email}
</p>
</div>
</div>
</button>
))}
</div>
</Card>
)}
{searchQuery && !searchLoading && searchResults.length === 0 && (
<p className="text-sm text-brand-purple text-center py-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No members found matching "{searchQuery}"
</p>
)}
</div>
) : (
/* Selected User Card */
<Card className="p-4 bg-[var(--lavender-400)] border-2 border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
<User className="h-6 w-6 text-brand-purple" />
</div>
<div>
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedUser.first_name} {selectedUser.last_name}
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedUser.email}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearUser}
className="text-brand-purple hover:bg-[var(--neutral-800)]/20"
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
)}
{/* Payment Form - Only show when user is selected */}
{selectedUser && (
<>
{/* Plan Selection */}
<div className="space-y-2">
<Label htmlFor="plan_id" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Plan
</Label>
<Select
value={formData.plan_id}
onValueChange={(value) => {
const plan = plans.find(p => p.id === value);
setSelectedPlan(plan);
const suggestedAmount = plan ? (plan.suggested_price_cents || plan.minimum_price_cents || plan.price_cents) / 100 : '';
setFormData({
...formData,
plan_id: value,
amount: suggestedAmount ? suggestedAmount.toFixed(2) : ''
});
}}
>
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Select subscription plan" />
</SelectTrigger>
<SelectContent>
{plans.map(plan => {
const minPrice = (plan.minimum_price_cents || plan.price_cents) / 100;
const sugPrice = plan.suggested_price_cents ? (plan.suggested_price_cents / 100) : null;
return (
<SelectItem key={plan.id} value={plan.id}>
{plan.name} - ${minPrice.toFixed(2)}{sugPrice && sugPrice > minPrice ? ` (Suggested: $${sugPrice.toFixed(2)})` : ''}/{plan.billing_cycle}
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedPlan && (
<p className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedPlan.description || `${selectedPlan.billing_cycle} subscription`}
</p>
)}
</div>
{/* Payment Amount */}
<div className="space-y-2">
<Label htmlFor="amount" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Amount ($)
</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
placeholder="Enter amount"
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
required
/>
{selectedPlan && (
<p className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)}
</p>
)}
</div>
{/* Amount Breakdown */}
{breakdown && breakdown.total >= breakdown.base && (
<Card className="p-4 bg-[var(--lavender-400)] border border-[var(--neutral-800)]">
<div className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div className="flex justify-between text-[var(--purple-ink)]">
<span>Membership Fee:</span>
<span className="font-semibold">{formatPrice(breakdown.base)}</span>
</div>
{breakdown.donation > 0 && (
<div className="flex justify-between text-[var(--orange-light)]">
<span className="flex items-center gap-1">
<Heart className="h-4 w-4" />
Additional Donation:
</span>
<span className="font-semibold">{formatPrice(breakdown.donation)}</span>
</div>
)}
<div className="flex justify-between text-[var(--purple-ink)] font-bold text-base pt-2 border-t border-[var(--neutral-800)]">
<span>Total:</span>
<span>{formatPrice(breakdown.total)}</span>
</div>
</div>
</Card>
)}
{/* Payment Date */}
<div className="space-y-2">
<Label htmlFor="payment_date" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Date
</Label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple" />
<Input
id="payment_date"
type="date"
value={formData.payment_date}
onChange={(e) => setFormData({ ...formData, payment_date: e.target.value })}
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
required
/>
</div>
</div>
{/* Payment Method */}
<div className="space-y-2">
<Label htmlFor="payment_method" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Method
</Label>
<Select
value={formData.payment_method}
onValueChange={(value) => setFormData({ ...formData, payment_method: value })}
>
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Select payment method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cash">Cash</SelectItem>
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
{/* Subscription Period */}
<div className="space-y-3">
<Label className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Period
</Label>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="use_custom_period"
checked={useCustomPeriod}
onChange={(e) => setUseCustomPeriod(e.target.checked)}
className="rounded border-[var(--neutral-800)]"
/>
<Label htmlFor="use_custom_period" className="text-sm text-brand-purple font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Use custom dates instead of plan's billing cycle
</Label>
</div>
{useCustomPeriod ? (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="custom_period_start" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Start Date
</Label>
<Input
id="custom_period_start"
type="date"
value={formData.custom_period_start}
onChange={(e) => setFormData({ ...formData, custom_period_start: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
required={useCustomPeriod}
/>
</div>
<div className="space-y-2">
<Label htmlFor="custom_period_end" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
End Date
</Label>
<Input
id="custom_period_end"
type="date"
value={formData.custom_period_end}
onChange={(e) => setFormData({ ...formData, custom_period_end: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
required={useCustomPeriod}
/>
</div>
</div>
) : (
selectedPlan && (
<div className="text-sm text-brand-purple bg-[var(--lavender-300)] p-3 rounded-lg space-y-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{selectedPlan.custom_cycle_enabled ? (
<>
<p>
<span className="font-medium text-[var(--purple-ink)]">Plan uses custom billing cycle:</span>
<br />
{(() => {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const startMonth = months[(selectedPlan.custom_cycle_start_month || 1) - 1];
const endMonth = months[(selectedPlan.custom_cycle_end_month || 12) - 1];
return `${startMonth} ${selectedPlan.custom_cycle_start_day} - ${endMonth} ${selectedPlan.custom_cycle_end_day} (recurring annually)`;
})()}
</p>
<p className="text-xs">
Subscription will end on the upcoming cycle end date based on today's date.
</p>
</>
) : (
<p>
Will use plan's billing cycle: <span className="font-medium">{selectedPlan.billing_cycle}</span>
<br />
Starts today, ends {selectedPlan.billing_cycle === 'monthly' ? '30 days' :
selectedPlan.billing_cycle === 'quarterly' ? '90 days' :
selectedPlan.billing_cycle === 'yearly' ? '1 year' :
selectedPlan.billing_cycle === 'lifetime' ? 'lifetime' : '1 year'} from now
</p>
)}
</div>
)
)}
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
Notes (Optional)
</Label>
<Textarea
id="notes"
placeholder="Additional notes about the payment..."
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
disabled={loading || !selectedUser}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Repeat className="h-4 w-4 mr-2" />
Create Subscription
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default CreateSubscriptionDialog;

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import api from '../utils/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { toast } from 'sonner';
import { Loader2, Mail, Copy, Check } from 'lucide-react';
const InviteMemberDialog = ({ open, onOpenChange, onSuccess }) => {
const [formData, setFormData] = useState({
email: '',
first_name: '',
last_name: '',
phone: '',
role: 'admin'
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const [invitationUrl, setInvitationUrl] = useState(null);
const [copied, setCopied] = useState(false);
const [roles, setRoles] = useState([]);
const [loadingRoles, setLoadingRoles] = useState(false);
// Fetch roles when dialog opens
useEffect(() => {
if (open) {
fetchRoles();
}
}, [open]);
const fetchRoles = async () => {
setLoadingRoles(true);
try {
// New endpoint returns roles based on user's permission level
// Superadmin: all roles
// Admin: admin, finance, and non-elevated custom roles
const response = await api.get('/admin/roles/assignable');
setRoles(response.data);
} catch (error) {
console.error('Failed to fetch assignable roles:', error);
toast.error('Failed to load roles. Please try again.');
} finally {
setLoadingRoles(false);
}
};
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) {
return;
}
setLoading(true);
try {
const response = await api.post('/admin/users/invite', formData);
toast.success('Invitation sent successfully');
// Show invitation URL
setInvitationUrl(response.data.invitation_url);
// Don't close dialog yet - show invitation URL first
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to send invitation';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(invitationUrl);
setCopied(true);
toast.success('Invitation link copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
const handleClose = () => {
// Reset form
setFormData({
email: '',
first_name: '',
last_name: '',
phone: '',
role: 'admin'
});
setInvitationUrl(null);
setCopied(false);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6" />
{invitationUrl ? 'Invitation Sent' : 'Invite Member'}
</DialogTitle>
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{invitationUrl
? 'The invitation has been sent via email. You can also copy the link below.'
: 'Send an email invitation to join as member. They will set their own password.'}
</DialogDescription>
</DialogHeader>
{invitationUrl ? (
// Show invitation URL after successful send
<div className="py-4">
<Label className="text-[var(--purple-ink)] mb-2 block">Invitation Link (expires in 7 days)</Label>
<div className="flex gap-2">
<Input
value={invitationUrl}
readOnly
className="rounded-xl border-2 border-[var(--neutral-800)] bg-gray-50"
/>
<Button
onClick={copyToClipboard}
className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white flex-shrink-0"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy
</>
)}
</Button>
</div>
</div>
) : (
// Show invitation form
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email */}
<div className="grid gap-2">
<Label htmlFor="email" className="text-[var(--purple-ink)]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="member@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* First Name (Optional) */}
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
First Name <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Jane"
/>
</div>
{/* Last Name (Optional) */}
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
Last Name <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Doe"
/>
</div>
{/* Phone (Optional) */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
Phone <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
placeholder="(555) 123-4567"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Send Invitation
</>
)}
</Button>
</DialogFooter>
</form>
)}
{invitationUrl && (
<DialogFooter>
<Button
onClick={handleClose}
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
>
Done
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
};
export default InviteMemberDialog;

View File

@@ -2,15 +2,15 @@
import React from 'react'; import React from 'react';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { getTierForMember } from '../utils/member-tiers'; import { getTierForMember } from '../utils/member-tiers';
import { MEMBER_TIER_ICONS } from '../config/memberTierIcons'; import { getTierIcon } from '../config/memberTierIcons';
const MemberBadge = ({ memberSince, tiers }) => { const MemberBadge = ({ memberSince, tiers }) => {
const tier = getTierForMember(memberSince, tiers); const tier = getTierForMember(memberSince, tiers);
const Icon = MEMBER_TIER_ICONS[tier.icon] || MEMBER_TIER_ICONS.FaUser; const Icon = getTierIcon(tier.iconKey);
return ( return (
<Badge className={`px-3 py-1 rounded-md text-sm flex items-center gap-2 ${tier.badgeClass}`}> <Badge className={`px-3 py-2 rounded-md text-sm flex items-center gap-2 border hover:text-white ${tier.badgeClass}`}>
<Icon className="h-4 w-4" /> <Icon className="size-6" />
{tier.label} {tier.label}
</Badge> </Badge>
); );

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react'; import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
import MemberBadge from './MemberBadge';
// Helper function to get initials // Helper function to get initials
const getInitials = (firstName, lastName) => { const getInitials = (firstName, lastName) => {
@@ -17,13 +18,13 @@ const getSocialMediaLink = (url) => {
return url; return url;
}; };
const MemberCard = ({ member, onViewProfile }) => { const MemberCard = ({ member, onViewProfile, tiers }) => {
const joinedDate = member.created_at; const memberSince = member.member_since || member.created_at;
return ( return (
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full"> <Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
{/* Profile Photo */} {/* Member Tier Badge */}
<div className='flex justify-end items-center'> <div className='flex justify-end items-center mb-2'>
member since badge <MemberBadge memberSince={memberSince} tiers={tiers} />
</div> </div>
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
{member.profile_photo_url ? ( {member.profile_photo_url ? (
@@ -64,11 +65,11 @@ const MemberCard = ({ member, onViewProfile }) => {
)} )}
{/* Member Since */} {/* Member Since */}
{joinedDate && ( {memberSince && (
<div className="flex items-center justify-center gap-2 mb-4"> <div className="flex items-center justify-center gap-2 mb-4">
<Calendar className="h-4 w-4 text-brand-purple " /> <Calendar className="h-4 w-4 text-brand-purple " />
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Member since {new Date(joinedDate).toLocaleDateString('en-US', { Member since {new Date(memberSince).toLocaleDateString('en-US', {
month: 'long', month: 'long',
year: 'numeric' year: 'numeric'
})} })}

View File

@@ -111,7 +111,7 @@ const MemberFooter = () => {
<a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</a> <a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</a>
<a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a> <a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
</div> </div>
<p>© 2025 LOAF. All rights reserved.</p> <p>© {new Date().getFullYear()} LOAF. All rights reserved.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ChevronDown, Menu, X } from 'lucide-react'; import { ChevronDown, Menu, X } from 'lucide-react';
import { import {
@@ -12,11 +13,12 @@ import {
const Navbar = () => { const Navbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// LOAF logo (local) // Get logo URL from theme config (with fallback to default)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const loafLogo = getLogoUrl();
const handleLogout = () => { const handleLogout = () => {
logout(); logout();

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,9 +1,11 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useThemeConfig } from '../context/ThemeConfigContext';
const PublicFooter = () => { const PublicFooter = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return ( return (
<> <>
@@ -60,7 +62,7 @@ const PublicFooter = () => {
</Link> </Link>
</nav> </nav>
<p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
© 2025 LOAF. All Rights Reserved. © {new Date().getFullYear()} LOAF. All Rights Reserved.
</p> </p>
<p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-3 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-3 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Designed and Managed by{' '} Designed and Managed by{' '}

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import { ChevronDown, Menu, X } from 'lucide-react'; import { ChevronDown, Menu, X } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@@ -12,6 +13,7 @@ import {
const PublicNavbar = () => { const PublicNavbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -30,8 +32,8 @@ const PublicNavbar = () => {
return location.pathname.startsWith('/about'); return location.pathname.startsWith('/about');
}; };
// LOAF logo (local) // Get logo URL from theme config (with fallback to default)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const loafLogo = getLogoUrl();
const handleAuthAction = () => { const handleAuthAction = () => {
if (user) { if (user) {

View File

@@ -1,46 +1,45 @@
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { CreditCard, Shield, Settings, Star } from 'lucide-react'; import { CreditCard, Shield, Star, Palette, FileEdit } from 'lucide-react';
const settingsItems = [ const settingsItems = [
{ label: 'Stripe Integration', 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 },
]; ];
const SettingsSidebar = () => { const SettingsTabs = () => {
const location = useLocation();
return ( return (
<aside className="w-full lg:w-64 shrink-0 bg-background border border-[var(--neutral-800)] rounded-2xl p-4 lg:sticky lg:top-8 h-max"> <div className="w-full border-b border-border">
<div className="flex items-center gap-2 px-2 pb-3 border-b border-[var(--neutral-800)]"> <nav className="flex gap-1 overflow-x-auto pb-px -mb-px" aria-label="Settings tabs">
<Settings className="h-4 w-4 text-[var(--purple-ink)]" />
<span className="text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Settings
</span>
</div>
<nav className="mt-3 space-y-1">
{settingsItems.map((item) => { {settingsItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = location.pathname === item.path;
return ( return (
<NavLink <NavLink
key={item.label} key={item.label}
to={item.path} to={item.path}
className={({ isActive }) => className={`
[ flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', border-b-2 transition-all duration-200
isActive ${isActive
? 'bg-[var(--orange-light)]/10 text-[var(--purple-ink)]' ? 'border-primary text-primary bg-primary/5'
: 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/10', : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
].join(' ') }
} `}
> >
<Icon className="h-4 w-4" /> <Icon className={`h-4 w-4 ${isActive ? 'text-primary' : ''}`} />
<span>{item.label}</span> <span>{item.label}</span>
</NavLink> </NavLink>
); );
})} })}
</nav> </nav>
</aside> </div>
); );
}; };
export default SettingsSidebar; export default SettingsTabs;

View File

@@ -12,27 +12,10 @@ export const StatCard = ({
const digitCount = const digitCount =
valueString.replace(/\D/g, "").length || valueString.length; valueString.replace(/\D/g, "").length || valueString.length;
/**
*
const getValueFontSize = () => {
if (digitCount <= 3) {
// 3.75rem for 3 or fewer digits
return "3.75rem";
} else if (digitCount <= 6) {
// Scale down for more digits
return "clamp(2rem, 5cqi, 3rem)";
} else if (digitCount <= 9) {
return "clamp(1.5rem, 4cqi, 2.5rem)";
} else {
return "clamp(1.25rem, 3cqi, 2rem)";
}
};
* */
const getValueFontSize = () => { const getValueFontSize = () => {
switch (true) { switch (true) {
case digitCount <= 3: case digitCount <= 2:
// 3.75rem for 3 or fewer digits // 3.75rem for 3 or fewer digits
return "3.75rem"; return "3.75rem";
case digitCount <= 6: case digitCount <= 6:
@@ -68,8 +51,8 @@ const getValueFontSize = () => {
</p> </p>
</div> </div>
<div className={`${iconBgClass} p-3 rounded-lg `}> <div className={`${iconBgClass} px-3 py-2 rounded-lg `}>
<Icon className="size-8" /> <Icon className="size-[valueFontSize]" />
</div> </div>
</div> </div>
<p <p

View File

@@ -6,7 +6,6 @@ const STATUS_BADGE_CONFIG = {
//status-based badges //status-based badges
pending_email: { label: 'Pending Email', variant: 'orange2' }, pending_email: { label: 'Pending Email', variant: 'orange2' },
pending_validation: { label: 'Pending Validation', variant: 'gray' }, pending_validation: { label: 'Pending Validation', variant: 'gray' },
pre_validated: { label: 'Pre-Validated', variant: 'green' },
payment_pending: { label: 'Payment Pending', variant: 'orange' }, payment_pending: { label: 'Payment Pending', variant: 'orange' },
active: { label: 'Active', variant: 'green' }, active: { label: 'Active', variant: 'green' },
inactive: { label: 'Inactive', variant: 'gray2' }, inactive: { label: 'Inactive', variant: 'gray2' },
@@ -23,7 +22,12 @@ const STATUS_BADGE_CONFIG = {
admin: { label: 'Admin', variant: 'purple' }, admin: { label: 'Admin', variant: 'purple' },
moderator: { label: 'Moderator', variant: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' }, moderator: { label: 'Moderator', variant: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' },
staff: { label: 'Staff', variant: 'gray' }, staff: { label: 'Staff', variant: 'gray' },
media: { label: 'Media', variant: 'gray2' } media: { label: 'Media', variant: 'gray2' },
//donation badges
pending: { label: 'Payment Pending', variant: 'orange' },
completed: { label: 'Completed', variant: 'green' },
failed: { label: 'Failed', className: 'bg-red-100 text-red-700' }
}; };
//todo: make shield icon dynamic based on status //todo: make shield icon dynamic based on status

View File

@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { Card } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Receipt, CreditCard, Heart, Calendar, ExternalLink, DollarSign } from 'lucide-react';
/**
* TransactionHistory Component
* Displays user transaction history including subscriptions and donations
*
* @param {Object} props
* @param {Array} props.subscriptions - List of subscription transactions
* @param {Array} props.donations - List of donation transactions
* @param {number} props.totalSubscriptionCents - Total subscription amount in cents
* @param {number} props.totalDonationCents - Total donation amount in cents
* @param {boolean} props.loading - Loading state
* @param {boolean} props.isAdmin - Whether viewing as admin (shows extra fields)
*/
const TransactionHistory = ({
subscriptions = [],
donations = [],
totalSubscriptionCents = 0,
totalDonationCents = 0,
loading = false,
isAdmin = false
}) => {
const [activeTab, setActiveTab] = useState('all');
const formatAmount = (cents) => {
if (!cents) return '$0.00';
return `$${(cents / 100).toFixed(2)}`;
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getStatusBadgeClass = (status) => {
switch (status?.toLowerCase()) {
case 'active':
case 'completed':
return 'bg-green-100 text-green-800 border-green-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'cancelled':
case 'failed':
case 'expired':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const allTransactions = [
...subscriptions.map(s => ({ ...s, sortDate: s.created_at })),
...donations.map(d => ({ ...d, sortDate: d.created_at }))
].sort((a, b) => new Date(b.sortDate) - new Date(a.sortDate));
const TransactionRow = ({ transaction }) => {
const isSubscription = transaction.type === 'subscription';
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-[var(--neutral-800)] last:border-b-0 hover:bg-[var(--lavender-500)] transition-colors">
<div className="flex items-start gap-3 mb-2 sm:mb-0">
<div className={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
{isSubscription ? (
<CreditCard className="h-5 w-5 text-white" />
) : (
<Heart className="h-5 w-5 text-white" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{transaction.description}
</span>
<Badge className={`text-xs ${getStatusBadgeClass(transaction.status)}`}>
{transaction.status}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-brand-purple mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Calendar className="h-3 w-3" />
<span>{formatDate(transaction.payment_completed_at || transaction.created_at)}</span>
{transaction.card_brand && transaction.card_last4 && (
<>
<span className="text-[var(--neutral-800)]"></span>
<span>{transaction.card_brand} ****{transaction.card_last4}</span>
</>
)}
{isSubscription && transaction.billing_cycle && (
<>
<span className="text-[var(--neutral-800)]"></span>
<span className="capitalize">{transaction.billing_cycle}</span>
</>
)}
</div>
{isAdmin && transaction.manual_payment && (
<div className="text-xs text-[var(--orange-light)] mt-1">
Manual Payment {transaction.manual_payment_notes && `- ${transaction.manual_payment_notes}`}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 pl-10 sm:pl-0">
<div className="text-right">
<div className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatAmount(transaction.amount_cents)}
</div>
{isSubscription && transaction.donation_cents > 0 && (
<div className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
(incl. {formatAmount(transaction.donation_cents)} donation)
</div>
)}
</div>
{transaction.stripe_receipt_url && (
<a
href={transaction.stripe_receipt_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-brand-purple hover:text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg transition-colors"
title="View Receipt"
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
</div>
);
};
const EmptyState = ({ type }) => (
<div className="py-12 text-center">
<div className="mx-auto w-16 h-16 bg-[var(--lavender-300)] rounded-full flex items-center justify-center mb-4">
{type === 'subscription' ? (
<CreditCard className="h-8 w-8 text-brand-purple" />
) : type === 'donation' ? (
<Heart className="h-8 w-8 text-brand-purple" />
) : (
<Receipt className="h-8 w-8 text-brand-purple" />
)}
</div>
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{type === 'subscription'
? 'No subscription payments yet'
: type === 'donation'
? 'No donations yet'
: 'No transactions yet'}
</p>
</div>
);
if (loading) {
return (
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--purple-lavender)]"></div>
</div>
</Card>
);
}
return (
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Receipt className="h-6 w-6 text-brand-purple" />
Transaction History
</h2>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<CreditCard className="h-4 w-4" />
<span className="text-sm">Total Subscriptions</span>
</div>
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatAmount(totalSubscriptionCents)}
</div>
<div className="text-xs text-brand-purple mt-1">{subscriptions.length} payment(s)</div>
</div>
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Heart className="h-4 w-4" />
<span className="text-sm">Total Donations</span>
</div>
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatAmount(totalDonationCents)}
</div>
<div className="text-xs text-brand-purple mt-1">{donations.length} donation(s)</div>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 mb-4">
<TabsTrigger value="all" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
All ({allTransactions.length})
</TabsTrigger>
<TabsTrigger value="subscriptions" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
Subscriptions ({subscriptions.length})
</TabsTrigger>
<TabsTrigger value="donations" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
Donations ({donations.length})
</TabsTrigger>
</TabsList>
<div className="border border-[var(--neutral-800)] rounded-xl overflow-hidden">
<TabsContent value="all" className="m-0">
{allTransactions.length > 0 ? (
allTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))
) : (
<EmptyState type="all" />
)}
</TabsContent>
<TabsContent value="subscriptions" className="m-0">
{subscriptions.length > 0 ? (
subscriptions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))
) : (
<EmptyState type="subscription" />
)}
</TabsContent>
<TabsContent value="donations" className="m-0">
{donations.length > 0 ? (
donations.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))
) : (
<EmptyState type="donation" />
)}
</TabsContent>
</div>
</Tabs>
</Card>
);
};
export default TransactionHistory;

View File

@@ -0,0 +1,539 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
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 StatusBadge from './StatusBadge';
import api from '../utils/api';
import { toast } from 'sonner';
const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
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) => {
if (!dateString) return '—';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatDateTime = (dateString) => {
if (!dateString) return '—';
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatPhoneNumber = (phone) => {
if (!phone) return '—';
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
}
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 }) => (
<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">
<Icon className="h-5 w-5 text-brand-purple" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{label}
</p>
<p className="font-medium text-[var(--purple-ink)] break-words" style={{ fontFamily: "'Inter', sans-serif" }}>
{value || '—'}
</p>
</div>
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<FileText className="h-6 w-6" />
Registration Details
</DialogTitle>
<DialogDescription className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View the registration information for this member application.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* User Header Card */}
<Card className="p-4 bg-[var(--lavender-400)] border-2 border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
<User className="h-8 w-8 text-brand-purple" />
</div>
<div className="flex-1">
<p className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.email}
</p>
<div className="mt-2">
<StatusBadge status={user.status} />
</div>
</div>
</div>
</Card>
{/* Contact 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" }}>
Contact Information
</h3>
<InfoRow icon={Mail} label="Email Address" value={user.email} />
<InfoRow icon={Phone} label="Phone Number" value={formatPhoneNumber(user.phone)} />
</Card>
{/* Registration Details */}
<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" }}>
Registration Details
</h3>
<InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} />
<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)} />
</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) */}
{(user.address || user.city || user.state || user.zip_code) && (
<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" }}>
Address
</h3>
<div className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.address && <p>{user.address}</p>}
{(user.city || user.state || user.zip_code) && (
<p>
{[user.city, user.state, user.zip_code].filter(Boolean).join(', ')}
</p>
)}
</div>
</Card>
)}
{/* Notes (if available) */}
{user.notes && (
<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" }}>
Notes
</h3>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.notes}
</p>
</Card>
)}
{/* Rejection Reason (if rejected) */}
{user.status === 'rejected' && user.rejection_reason && (
<Card className="p-4 border border-red-300 bg-red-50 dark:bg-red-500/10 rounded-xl">
<h3 className="text-lg font-semibold text-red-600 mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Rejection Reason
</h3>
<p className="text-red-600" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{user.rejection_reason}
</p>
</Card>
)}
</div>
<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
type="button"
onClick={() => onOpenChange(false)}
className="rounded-xl bg-[var(--purple-ink)] hover:bg-[var(--purple-ink)]/90 text-white"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ViewRegistrationDialog;

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,347 @@
import React from 'react';
import { Button } from '../ui/button';
import StatusBadge from '../StatusBadge';
import {
ChevronDown,
ChevronUp,
Edit,
XCircle,
CreditCard,
Info,
ExternalLink,
Copy
} from 'lucide-react';
const HEADER_CELLS = [
{ label: 'Member', align: 'text-left' },
{ label: 'Plan', align: 'text-left' },
{ label: 'Status', align: 'text-left' },
{ label: 'Period', align: 'text-left' },
{ label: 'Base Fee', align: 'text-right' },
{ label: 'Donation', align: 'text-right' },
{ label: 'Total', align: 'text-right' },
{ label: 'Details', align: 'text-center' },
{ label: 'Actions', align: 'text-center' }
];
const HeaderCell = ({ align, children }) => (
<th
className={`p-4 text-[var(--purple-ink)] font-semibold ${align}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
{children}
</th>
);
const TableCell = ({ align = 'text-left', className = '', style, children, ...props }) => (
<td
className={`p-4 ${align} ${className}`.trim()}
style={style}
{...props}
>
{children}
</td>
);
const SubscriptionRow = ({
sub,
isExpanded,
onToggle,
onEdit,
onCancel,
hasPermission,
formatDate,
formatDateTime,
formatPrice,
copyToClipboard
}) => (
<>
<tr className="border-b border-[var(--neutral-800)] hover:bg-[var(--lavender-400)] transition-colors">
<TableCell>
<div className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.user.first_name} {sub.user.last_name}
</div>
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.user.email}
</div>
</TableCell>
<TableCell>
<div className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.plan.name}
</div>
<div className="text-xs text-brand-purple ">
{sub.plan.billing_cycle}
</div>
</TableCell>
<TableCell>
<StatusBadge status={sub.status} />
</TableCell>
<TableCell>
<div className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div>{formatDate(sub.start_date)}</div>
<div className="text-xs text-brand-purple ">to {formatDate(sub.end_date)}</div>
</div>
</TableCell>
<TableCell
align="text-right"
className="text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{formatPrice(sub.base_subscription_cents || 0)}
</TableCell>
<TableCell
align="text-right"
className="text-[var(--orange-light)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{formatPrice(sub.donation_cents || 0)}
</TableCell>
<TableCell
align="text-right"
className="font-semibold text-[var(--purple-ink)]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{formatPrice(sub.amount_paid_cents || 0)}
</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
onClick={onToggle}
className="text-brand-purple hover:bg-[var(--neutral-800)]"
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-2">
{hasPermission('subscriptions.edit') && (
<Button
size="sm"
variant="outline"
onClick={() => onEdit(sub)}
className="text-brand-purple hover:bg-[var(--neutral-800)]"
>
<Edit className="h-4 w-4" />
</Button>
)}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button
size="sm"
variant="outline-destructive"
onClick={() => onCancel(sub.id)}
>
<XCircle className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</tr>
{isExpanded && (
<tr className="bg-[var(--lavender-400)]/30">
<TableCell colSpan={9} className="p-6">
<div className="space-y-4">
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Transaction Details
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<CreditCard className="h-4 w-4" />
Payment Information
</h5>
<div className="space-y-2 text-sm">
{sub.payment_completed_at && (
<div className="flex justify-between">
<span className="text-brand-purple ">Payment Date:</span>
<span className="text-[var(--purple-ink)] font-medium">{formatDateTime(sub.payment_completed_at)}</span>
</div>
)}
{sub.payment_method && (
<div className="flex justify-between">
<span className="text-brand-purple ">Payment Method:</span>
<span className="text-[var(--purple-ink)] font-medium capitalize">{sub.payment_method}</span>
</div>
)}
{sub.card_brand && sub.card_last4 && (
<div className="flex justify-between">
<span className="text-brand-purple ">Card:</span>
<span className="text-[var(--purple-ink)] font-medium">{sub.card_brand} ****{sub.card_last4}</span>
</div>
)}
</div>
</div>
<div className="space-y-3">
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Info className="h-4 w-4" />
Stripe Transaction IDs
</h5>
<div className="space-y-2 text-sm">
{sub.stripe_payment_intent_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Payment Intent:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_payment_intent_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_payment_intent_id, 'Payment Intent ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_charge_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Charge ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_charge_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_charge_id, 'Charge ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_subscription_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Subscription ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_subscription_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_subscription_id, 'Subscription ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_invoice_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Invoice ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_invoice_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_invoice_id, 'Invoice ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_customer_id && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Customer ID:</span>
<div className="flex items-center gap-1">
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
{sub.stripe_customer_id.substring(0, 20)}...
</code>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(sub.stripe_customer_id, 'Customer ID')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{sub.stripe_receipt_url && (
<div className="flex items-center justify-between gap-2">
<span className="text-brand-purple ">Receipt:</span>
<Button
size="sm"
variant="outline"
onClick={() => window.open(sub.stripe_receipt_url, '_blank')}
className="text-brand-purple hover:bg-[var(--neutral-800)]"
>
<ExternalLink className="h-3 w-3 mr-1" />
View Receipt
</Button>
</div>
)}
</div>
</div>
</div>
</div>
</TableCell>
</tr>
)}
</>
);
const SubscriptionsTable = ({
subscriptions,
expandedRows,
onToggleRowExpansion,
onEdit,
onCancel,
hasPermission,
formatDate,
formatDateTime,
formatPrice,
copyToClipboard
}) => (
<table className="w-full">
<thead>
<tr className="bg-[var(--neutral-800)]/20 border-b border-[var(--neutral-800)]">
{HEADER_CELLS.map((cell) => (
<HeaderCell key={cell.label} align={cell.align}>
{cell.label}
</HeaderCell>
))}
</tr>
</thead>
<tbody>
{subscriptions.length > 0 ? (
subscriptions.map((sub) => (
<SubscriptionRow
key={sub.id}
sub={sub}
isExpanded={expandedRows.has(sub.id)}
onToggle={() => onToggleRowExpansion(sub.id)}
onEdit={onEdit}
onCancel={onCancel}
hasPermission={hasPermission}
formatDate={formatDate}
formatDateTime={formatDateTime}
formatPrice={formatPrice}
copyToClipboard={copyToClipboard}
/>
))
) : (
<tr>
<TableCell
align="text-center"
className="p-12 text-brand-purple "
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
colSpan={9}
>
No subscriptions found
</TableCell>
</tr>
)}
</tbody>
</table>
);
export default SubscriptionsTable;

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

@@ -9,7 +9,7 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", "border-transparent bg-primary text-primary-foreground shadow ",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:

View File

@@ -83,16 +83,16 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:text-white focus:text-white",
className className
)} )}
{...props}> {...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center ">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText className="">{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ))
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName

View File

@@ -1,78 +1,91 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Table = React.forwardRef(({ className, ...props }, ref) => ( const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto">
<table <table ref={ref} className={cn("w-full", className)} {...props} />
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div> </div>
)) ));
Table.displayName = "Table" Table.displayName = "Table";
const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead
)) ref={ref}
TableHeader.displayName = "TableHeader" className={cn(
"bg-[var(--lavender-300)] border-b border-[var(--neutral-800)]",
className,
)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef(({ className, ...props }, ref) => ( const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody <tbody
ref={ref} ref={ref}
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} /> {...props}
)) />
TableBody.displayName = "TableBody" ));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} className={cn(
{...props} /> "border-t border-[var(--neutral-800)] font-medium [&>tr]:last:border-b-0",
)) className,
TableFooter.displayName = "TableFooter" )}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef(({ className, ...props }, ref) => ( const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b border-[var(--neutral-800)] transition-colors hover:bg-[var(--lavender-400)]",
className className,
)} )}
{...props} /> {...props}
)) />
TableRow.displayName = "TableRow" ));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef(({ className, ...props }, ref) => ( const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-4 text-left align-middle font-semibold text-[var(--purple-ink)] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} /> {...props}
)) />
TableHead.displayName = "TableHead" ));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef(({ className, ...props }, ref) => ( const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn( className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] ",
className className,
)} )}
{...props} /> {...props}
)) />
TableCell.displayName = "TableCell" ));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef(({ className, ...props }, ref) => ( const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption <caption
ref={ref} ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} /> {...props}
)) />
TableCaption.displayName = "TableCaption" ));
TableCaption.displayName = "TableCaption";
export { export {
Table, Table,
@@ -83,4 +96,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View File

@@ -1,27 +1,70 @@
// src/config/memberTiers.js // src/config/memberTiers.js
/**
* Default member tier configuration
* Used as fallback when API is unavailable
* Format matches backend MemberTier interface
*/
export const DEFAULT_MEMBER_TIERS = [ export const DEFAULT_MEMBER_TIERS = [
{ {
id: 'new', id: 'new_member',
label: 'New Member', label: 'New Member',
minDays: 0, minYears: 0,
maxDays: 364, // < 1 year maxYears: 0.999,
icon: 'FaSeedling', iconKey: 'sparkle',
badgeClass: 'bg-[var(--lavender-300)] text-[var(--purple-ink)] hover:text-white', badgeClass: 'bg-blue-100 text-blue-800 border-blue-200',
}, },
{ {
id: 'silver', id: 'member_1_year',
label: 'Silver Member', label: '1 Year Member',
minDays: 365, minYears: 1,
maxDays: 729, maxYears: 2.999,
icon: 'FaMedal', iconKey: 'star',
badgeClass: 'bg-slate-200 text-slate-900 hover:text-white', badgeClass: 'bg-green-100 text-green-800 border-green-200',
}, },
{ {
id: 'gold', id: 'member_3_year',
label: 'Gold Member', label: '3+ Year Member',
minDays: 730, minYears: 3,
maxDays: null, // open-ended maxYears: 4.999,
icon: 'FaCrown', iconKey: 'award',
badgeClass: 'bg-amber-200 text-amber-900 hover:text-white', badgeClass: 'bg-purple-100 text-purple-800 border-purple-200',
},
{
id: 'veteran',
label: 'Veteran Member',
minYears: 5,
maxYears: 999,
iconKey: 'crown',
badgeClass: 'bg-amber-100 text-amber-800 border-amber-200',
}, },
]; ];
/**
* Available icon options for tier configuration
*/
export const TIER_ICON_OPTIONS = [
{ key: 'sparkle', label: 'Sparkle' },
{ key: 'star', label: 'Star' },
{ key: 'award', label: 'Award' },
{ key: 'crown', label: 'Crown' },
{ key: 'medal', label: 'Medal' },
{ key: 'trophy', label: 'Trophy' },
{ key: 'gem', label: 'Gem' },
{ key: 'heart', label: 'Heart' },
{ key: 'shield', label: 'Shield' },
];
/**
* Available badge color presets
*/
export const BADGE_COLOR_PRESETS = [
{ label: 'Blue', badgeClass: 'bg-blue-100 text-blue-800 border-blue-200' },
{ label: 'Green', badgeClass: 'bg-green-100 text-green-800 border-green-200' },
{ label: 'Purple', badgeClass: 'bg-purple-100 text-purple-800 border-purple-200' },
{ label: 'Amber', badgeClass: 'bg-amber-100 text-amber-800 border-amber-200' },
{ label: 'Red', badgeClass: 'bg-red-100 text-red-800 border-red-200' },
{ label: 'Teal', badgeClass: 'bg-teal-100 text-teal-800 border-teal-200' },
{ label: 'Pink', badgeClass: 'bg-pink-100 text-pink-800 border-pink-200' },
{ label: 'Indigo', badgeClass: 'bg-indigo-100 text-indigo-800 border-indigo-200' },
];

View File

@@ -1,9 +1,29 @@
// src/config/memberTierIcons.js // src/config/memberTierIcons.js
import { FaSeedling, FaMedal, FaCrown, FaUser } from 'react-icons/fa'; import { User, Star, Crown, Award, Sparkles, Medal, Trophy, Gem, Heart, Shield } from 'lucide-react';
/**
* Member tier icon mapping
* Maps iconKey strings from backend to Lucide React components
*/
export const MEMBER_TIER_ICONS = { export const MEMBER_TIER_ICONS = {
FaSeedling, // Primary tier icons
FaMedal, sparkle: Sparkles,
FaCrown, sparkles: Sparkles,
FaUser, star: Star,
award: Award,
crown: Crown,
// Additional options
medal: Medal,
trophy: Trophy,
gem: Gem,
heart: Heart,
shield: Shield,
user: User,
};
/**
* Get icon component by key with fallback
*/
export const getTierIcon = (iconKey) => {
return MEMBER_TIER_ICONS[iconKey?.toLowerCase()] || MEMBER_TIER_ICONS.sparkle;
}; };

View File

@@ -0,0 +1,161 @@
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
import axios from 'axios';
const ThemeConfigContext = createContext();
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
const DEFAULT_THEME = {
site_name: 'LOAF - Lesbians Over Age Fifty',
site_short_name: 'LOAF',
site_description: 'A community organization for lesbians over age fifty in Houston and surrounding areas.',
logo_url: null,
favicon_url: null,
colors: {
primary: '280 47% 27%',
primary_foreground: '0 0% 100%',
accent: '24 86% 55%',
brand_purple: '256 35% 47%',
brand_orange: '24 86% 55%',
brand_lavender: '262 46% 80%'
},
meta_theme_color: '#664fa3'
};
export const ThemeConfigProvider = ({ children }) => {
const [themeConfig, setThemeConfig] = useState(DEFAULT_THEME);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const applyThemeToDOM = useCallback((config) => {
// Apply CSS variables for colors
if (config.colors) {
const root = document.documentElement;
Object.entries(config.colors).forEach(([key, value]) => {
// Convert snake_case to kebab-case for CSS variable names
const cssVarName = `--${key.replace(/_/g, '-')}`;
root.style.setProperty(cssVarName, value);
});
}
// Update favicon
if (config.favicon_url) {
let link = document.querySelector("link[rel*='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = config.favicon_url;
}
// Update document title
if (config.site_name) {
document.title = config.site_name;
// Also store for use by pages that want to append their own title
window.__SITE_NAME__ = config.site_name;
}
// Update meta description
if (config.site_description) {
let metaDesc = document.querySelector("meta[name='description']");
if (!metaDesc) {
metaDesc = document.createElement('meta');
metaDesc.name = 'description';
document.head.appendChild(metaDesc);
}
metaDesc.content = config.site_description;
}
// Update meta theme-color for PWA
if (config.meta_theme_color) {
let meta = document.querySelector("meta[name='theme-color']");
if (!meta) {
meta = document.createElement('meta');
meta.name = 'theme-color';
document.head.appendChild(meta);
}
meta.content = config.meta_theme_color;
}
}, []);
const fetchThemeConfig = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await axios.get(`${API_URL}/api/config/theme`);
const config = { ...DEFAULT_THEME, ...response.data };
// Merge colors if provided
if (response.data.colors) {
config.colors = { ...DEFAULT_THEME.colors, ...response.data.colors };
}
setThemeConfig(config);
applyThemeToDOM(config);
} catch (err) {
console.warn('Failed to fetch theme config, using defaults:', err.message);
setError(err.message);
// Apply default theme to DOM
applyThemeToDOM(DEFAULT_THEME);
} finally {
setLoading(false);
}
}, [applyThemeToDOM]);
// Fetch theme config on mount
useEffect(() => {
fetchThemeConfig();
}, [fetchThemeConfig]);
// Helper function to get logo URL with fallback
const getLogoUrl = useCallback(() => {
return themeConfig.logo_url || `${process.env.PUBLIC_URL}/loaf-logo.png`;
}, [themeConfig.logo_url]);
// Helper function to get favicon URL with fallback
const getFaviconUrl = useCallback(() => {
return themeConfig.favicon_url || `${process.env.PUBLIC_URL}/favicon.ico`;
}, [themeConfig.favicon_url]);
const value = {
// Theme configuration
themeConfig,
loading,
error,
// Convenience accessors
siteName: themeConfig.site_name,
siteShortName: themeConfig.site_short_name,
siteDescription: themeConfig.site_description,
colors: themeConfig.colors,
metaThemeColor: themeConfig.meta_theme_color,
// Helper functions
getLogoUrl,
getFaviconUrl,
// Actions
refreshTheme: fetchThemeConfig,
// Default theme for reference
DEFAULT_THEME
};
return (
<ThemeConfigContext.Provider value={value}>
{children}
</ThemeConfigContext.Provider>
);
};
export const useThemeConfig = () => {
const context = useContext(ThemeConfigContext);
if (context === undefined) {
throw new Error('useThemeConfig must be used within a ThemeConfigProvider');
}
return context;
};
export default ThemeConfigContext;

View File

@@ -0,0 +1,106 @@
// src/hooks/use-member-tiers.js
import { useState, useEffect, useCallback } from 'react';
import api from '../utils/api';
import { DEFAULT_MEMBER_TIERS } from '../config/MemberTiers';
/**
* Hook for fetching and managing member tier configuration
* @param {Object} options
* @param {boolean} options.isAdmin - Whether to use admin endpoint (includes metadata)
* @returns {Object} Tier state and methods
*/
const useMemberTiers = ({ isAdmin = false } = {}) => {
const [tiers, setTiers] = useState(DEFAULT_MEMBER_TIERS);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const endpoint = isAdmin
? '/admin/settings/member-tiers'
: '/settings/member-tiers';
const fetchTiers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.get(endpoint);
const data = response.data?.tiers || response.data || DEFAULT_MEMBER_TIERS;
setTiers(data);
} catch (err) {
console.error('Failed to fetch member tiers:', err);
setError('Failed to load member tiers');
// Use defaults on error
setTiers(DEFAULT_MEMBER_TIERS);
} finally {
setLoading(false);
}
}, [endpoint]);
useEffect(() => {
fetchTiers();
}, [fetchTiers]);
/**
* Update tier configuration (admin only)
* @param {Array} newTiers - Updated tier array
* @returns {Promise<boolean>} Success status
*/
const updateTiers = useCallback(async (newTiers) => {
if (!isAdmin) {
console.error('updateTiers requires admin access');
return false;
}
try {
setSaving(true);
setError(null);
await api.put('/admin/settings/member-tiers', { tiers: newTiers });
setTiers(newTiers);
return true;
} catch (err) {
console.error('Failed to update member tiers:', err);
setError('Failed to save member tiers');
return false;
} finally {
setSaving(false);
}
}, [isAdmin]);
/**
* Reset tiers to defaults (superadmin only)
* @returns {Promise<boolean>} Success status
*/
const resetToDefaults = useCallback(async () => {
if (!isAdmin) {
console.error('resetToDefaults requires admin access');
return false;
}
try {
setSaving(true);
setError(null);
const response = await api.post('/admin/settings/member-tiers/reset');
const data = response.data?.tiers || response.data || DEFAULT_MEMBER_TIERS;
setTiers(data);
return true;
} catch (err) {
console.error('Failed to reset member tiers:', err);
setError('Failed to reset member tiers');
return false;
} finally {
setSaving(false);
}
}, [isAdmin]);
return {
tiers,
loading,
error,
saving,
fetchTiers,
updateTiers,
resetToDefaults,
};
};
export default useMemberTiers;

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { ThemeProvider } from 'next-themes'; import { ThemeProvider } from 'next-themes';
import { ThemeConfigProvider } from './context/ThemeConfigContext';
import '@fontsource/fraunces/600.css'; import '@fontsource/fraunces/600.css';
import '@fontsource/dm-sans/400.css'; import '@fontsource/dm-sans/400.css';
import '@fontsource/dm-sans/700.css'; import '@fontsource/dm-sans/700.css';
@@ -16,7 +17,9 @@ root.render(
enableSystem={false} enableSystem={false}
storageKey="admin-theme" storageKey="admin-theme"
> >
<App /> <ThemeConfigProvider>
<App />
</ThemeConfigProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -1,12 +1,27 @@
import React from 'react'; import React from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import SettingsSidebar from '../components/SettingsSidebar'; import SettingsTabs from '../components/SettingsSidebar';
import { Settings } from 'lucide-react';
const SettingsLayout = () => { const SettingsLayout = () => {
return ( return (
<div className="flex flex-col gap-6 lg:flex-row"> <div className="space-y-6">
<SettingsSidebar /> {/* Header */}
<div className="flex-1 min-w-0"> <div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Settings className="h-6 w-6" />
Settings
</h1>
<p className="text-muted-foreground mt-1">
Manage your platform configuration and preferences
</p>
</div>
{/* Tabs Navigation */}
<SettingsTabs />
{/* Content Area */}
<div className="min-w-0">
<Outlet /> <Outlet />
</div> </div>
</div> </div>

View File

@@ -9,6 +9,9 @@ import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter'; import MemberFooter from '../components/MemberFooter';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react'; import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import TransactionHistory from '../components/TransactionHistory';
import MemberBadge from '@/components/MemberBadge';
import useMemberTiers from '../hooks/use-member-tiers'
const Dashboard = () => { const Dashboard = () => {
const { user, resendVerificationEmail, refreshUser } = useAuth(); const { user, resendVerificationEmail, refreshUser } = useAuth();
@@ -17,12 +20,16 @@ const Dashboard = () => {
const [resendLoading, setResendLoading] = useState(false); const [resendLoading, setResendLoading] = useState(false);
const [eventActivity, setEventActivity] = useState(null); const [eventActivity, setEventActivity] = useState(null);
const [activityLoading, setActivityLoading] = useState(true); const [activityLoading, setActivityLoading] = useState(true);
const [transactionsLoading, setTransactionsLoading] = useState(true);
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [activeTransactionTab, setActiveTransactionTab] = useState('all'); const [activeTransactionTab, setActiveTransactionTab] = useState('all');
const joinedDate = user?.member_since || user?.created_at; const joinedDate = user?.member_since || user?.created_at;
const { tiers, loading: tiersLoading } = useMemberTiers();
useEffect(() => { useEffect(() => {
fetchUpcomingEvents(); fetchUpcomingEvents();
fetchEventActivity(); fetchEventActivity();
fetchTransactions();
}, []); }, []);
const fetchUpcomingEvents = async () => { const fetchUpcomingEvents = async () => {
@@ -48,6 +55,19 @@ const Dashboard = () => {
} }
}; };
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 handleResendVerification = async () => { const handleResendVerification = async () => {
setResendLoading(true); setResendLoading(true);
try { try {
@@ -72,6 +92,7 @@ const Dashboard = () => {
} }
}; };
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const statusConfig = { const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
@@ -112,6 +133,8 @@ const Dashboard = () => {
return messages[status] || ''; return messages[status] || '';
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Navbar /> <Navbar />
@@ -183,67 +206,68 @@ const Dashboard = () => {
{/* Grid Layout */} {/* Grid Layout */}
<div className="grid lg:grid-cols-2 gap-8"> <div className="grid lg:grid-cols-2 gap-8">
{/* Quick Stats */} {/* Quick Stats */}
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card"> <div className='space-y-8'>
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Quick Info
</h3>
<div className="space-y-4">
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
</div>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
</div>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
</p>
</div>
{user?.subscription_start_date && user?.subscription_end_date && (
<>
<div className="pt-4 border-t border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
</p>
</div>
</>
)}
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Membership Info
</h3>
<div className="space-y-4">
{user?.subscription_start_date && user?.subscription_end_date && ( <Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
<> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
<div className="pt-4 border-t border-[var(--neutral-800)]"> Quick Info
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p> </h3>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="space-y-4">
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()} {/* member date and badge */}
</p> <div className='flex justify-between'>
</div>
<div> <div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p> <p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
</p> </p>
</div> </div>
</> {!tiersLoading && (
)} <div className='lg:mr-10'>
</div> <MemberBadge memberSince={joinedDate} tiers={tiers} />
</Card> </div>
)}
</div>
{/* email */}
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
</div>
{/* role */}
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
</div>
</div>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Membership Info
</h3>
<div className="space-y-4">
{!user.subscription_end_date && !user.subscription_end_date && (
<div>No subscriptions yet</div>
)}
{user?.subscription_start_date && user?.subscription_end_date && (
<>
<div className="pt-4 border-t border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
</p>
</div>
</>
)}
</div>
</Card>
</div>
{/* Upcoming Events */} {/* Upcoming Events */}
<Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card"> <Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
@@ -337,127 +361,16 @@ const Dashboard = () => {
)} )}
{/* Transaction History Section */} {/* Transaction History Section */}
<Card className="mt-12 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <div className="mt-8">
{/* Header */} <TransactionHistory
<div className="flex items-center gap-3 mb-6"> subscriptions={transactions.subscriptions}
<div className="p-2 bg-[var(--green-light)] rounded-lg"> donations={transactions.donations}
<Receipt className="h-5 w-5 text-white" /> totalSubscriptionCents={transactions.total_subscription_amount_cents}
</div> totalDonationCents={transactions.total_donation_amount_cents}
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> loading={transactionsLoading}
Transaction History isAdmin={false}
</h2> />
</div> </div>
{/* Stats Row */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-[var(--lavender-300)]/30 rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 mb-2">
<CreditCard className="h-4 w-4 text-[var(--green-light)]" />
<span className="text-sm text-[var(--green-light)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Subscriptions
</span>
</div>
<p className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
$30.00
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
1 payment(s)
</p>
</div>
<div className="p-4 bg-[var(--lavender-300)]/30 rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 mb-2">
<Heart className="h-4 w-4 text-[var(--coral)]" />
<span className="text-sm text-[var(--coral)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Donations
</span>
</div>
<p className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
$0.00
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
0 donation(s)
</p>
</div>
</div>
{/* Filter Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTransactionTab('all')}
className={`px-6 py-2 rounded-full font-medium transition-all ${
activeTransactionTab === 'all'
? 'bg-[var(--purple-lavender)] text-white'
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
All (1)
</button>
<button
onClick={() => setActiveTransactionTab('subscriptions')}
className={`px-6 py-2 rounded-full font-medium transition-all ${
activeTransactionTab === 'subscriptions'
? 'bg-[var(--purple-lavender)] text-white'
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Subscriptions (1)
</button>
<button
onClick={() => setActiveTransactionTab('donations')}
className={`px-6 py-2 rounded-full font-medium transition-all ${
activeTransactionTab === 'donations'
? 'bg-[var(--purple-lavender)] text-white'
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Donations (0)
</button>
</div>
{/* Transaction List */}
<div className="space-y-4">
{(activeTransactionTab === 'all' || activeTransactionTab === 'subscriptions') && (
<div className="flex items-center justify-between p-4 border border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-[var(--purple-lavender)] rounded-lg"></div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Annual Membership
</span>
<Badge className="bg-[var(--green-light)] text-white text-xs px-2 py-0.5 rounded">
active
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Calendar className="h-3.5 w-3.5" />
<span>Dec 16, 2025</span>
<span></span>
<span>Custom</span>
</div>
<span className="text-sm text-[var(--coral)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manual Payment
</span>
</div>
</div>
<span className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
$30.00
</span>
</div>
)}
{activeTransactionTab === 'donations' && (
<div className="text-center py-8">
<Heart className="h-12 w-12 text-[var(--neutral-800)] mx-auto mb-3" />
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No donations yet
</p>
</div>
)}
</div>
</Card>
</div> </div>
<MemberFooter /> <MemberFooter />

View File

@@ -2,9 +2,11 @@ import React from 'react';
import PublicNavbar from '../components/PublicNavbar'; import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter'; import PublicFooter from '../components/PublicFooter';
import { Card } from '../components/ui/card'; import { Card } from '../components/ui/card';
import { useThemeConfig } from '../context/ThemeConfigContext';
const MissionValues = () => { const MissionValues = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">

File diff suppressed because it is too large Load Diff

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 = () => {
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 = () => { fetchSchema();
const { newsletter_publish_name, newsletter_publish_photo, }, []);
newsletter_publish_birthday, newsletter_publish_none } = formData;
if (!newsletter_publish_name && !newsletter_publish_photo && // Get sorted steps
!newsletter_publish_birthday && !newsletter_publish_none) { const sortedSteps = useMemo(() => {
toast.error('Please select at least one newsletter publication preference'); if (!schema?.steps) return [];
return false; return [...schema.steps].sort((a, b) => a.order - b.order);
} }, [schema]);
if (formData.scholarship_requested && !formData.scholarship_reason?.trim()) { // Get current step data
toast.error('Please explain your scholarship request'); const currentStepData = useMemo(() => {
return false; return sortedSteps[currentStep - 1] || null;
} }, [sortedSteps, currentStep]);
return true; // Get hidden fields based on conditional rules
}; const hiddenFields = useMemo(() => {
return evaluateConditionalRules(schema, formData);
}, [schema, formData]);
const validateStep3 = () => { // Validate current step
return true; // No required fields const validateCurrentStep = useCallback(() => {
}; if (!currentStepData) return { isValid: true, errors: {} };
return validateStep(currentStepData, formData, hiddenFields);
const validateStep4 = () => { }, [currentStepData, formData, hiddenFields]);
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' });
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
// Handle previous step
const handleBack = () => {
setErrors({});
setCurrentStep((prev) => Math.max(prev - 1, 1));
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
formData={formData} schema={schema}
setFormData={setFormData} formData={formData}
handleInputChange={handleInputChange} onFormDataChange={setFormData}
/> 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">
@@ -245,7 +264,7 @@ const Register = () => {
type="button" type="button"
onClick={handleBack} onClick={handleBack}
variant="outline" variant="outline"
className="rounded-full px-6 py-6 text-lg border-2 border-[var(--neutral-800)] hover:border-brand-purple text-[var(--purple-ink)]" className="rounded-full px-6 py-6 text-lg border-2 border-[var(--neutral-800)] hover:border-brand-purple text-[var(--purple-ink)]"
> >
<ArrowLeft className="mr-2 h-5 w-5" /> <ArrowLeft className="mr-2 h-5 w-5" />
Back Back
@@ -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 ? (
<ArrowRight className="ml-2 h-5 w-5" /> <>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Creating Account...
</>
) : (
<>
Create Account
<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

@@ -3,6 +3,7 @@ import { useAuth } from '../../context/AuthContext';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import StatusBadge from '@/components/StatusBadge';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -17,6 +18,14 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../components/ui/table';
import api from '../../utils/api'; import api from '../../utils/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -184,15 +193,8 @@ const AdminDonations = () => {
toast.error('Failed to copy to clipboard'); toast.error('Failed to copy to clipboard');
} }
}; };
/*
const getStatusBadgeVariant = (status) => { */
const variants = {
completed: 'default',
pending: 'secondary',
failed: 'destructive'
};
return variants[status] || 'outline';
};
const getTypeBadgeColor = (type) => { const getTypeBadgeColor = (type) => {
return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-brand-purple '; return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-brand-purple ';
@@ -392,51 +394,37 @@ const AdminDonations = () => {
{/* Donations Table */} {/* Donations Table */}
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden"> <Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <Table>
<thead className="bg-[var(--lavender-300)] border-b-2 border-[var(--neutral-800)]"> <TableHeader>
<tr> <TableRow>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead>Donor</TableHead>
Donor <TableHead>Type</TableHead>
</th> <TableHead>Amount</TableHead>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead>Status</TableHead>
Type <TableHead>Date</TableHead>
</th> <TableHead>Payment Method</TableHead>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead className="text-center">Details</TableHead>
Amount </TableRow>
</th> </TableHeader>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableBody>
Status
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Date
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Method
</th>
<th className="px-6 py-4 text-center text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Details
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--neutral-800)]">
{filteredDonations.length === 0 ? ( {filteredDonations.length === 0 ? (
<tr> <TableRow>
<td colSpan="7" className="px-6 py-12 text-center"> <TableCell colSpan={7} className="p-12 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<Heart className="h-12 w-12 text-[var(--neutral-800)]" /> <Heart className="h-12 w-12 text-[var(--neutral-800)]" />
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donations.length === 0 ? 'No donations yet' : 'No donations match your filters'} {donations.length === 0 ? 'No donations yet' : 'No donations match your filters'}
</p> </p>
</div> </div>
</td> </TableCell>
</tr> </TableRow>
) : ( ) : (
filteredDonations.map((donation) => { filteredDonations.map((donation) => {
const isExpanded = expandedRows.has(donation.id); const isExpanded = expandedRows.has(donation.id);
return ( return (
<React.Fragment key={donation.id}> <React.Fragment key={donation.id}>
<tr className="hover:bg-[var(--lavender-400)] transition-colors"> <TableRow>
<td className="px-6 py-4"> <TableCell>
<div> <div>
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.donor_name || 'Anonymous'} {donation.donor_name || 'Anonymous'}
@@ -445,39 +433,37 @@ const AdminDonations = () => {
{donation.donor_email || 'No email'} {donation.donor_email || 'No email'}
</p> </p>
</div> </div>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<Badge <Badge
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none rounded-full px-3 py-1`} className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none px-3 py-1`}
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
{donation.donation_type === 'member' ? 'Member' : 'Public'} {donation.donation_type === 'member' ? 'Member' : 'Public'}
</Badge> </Badge>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.amount} {donation.amount}
</p> </p>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<Badge variant={getStatusBadgeVariant(donation.status)} className="rounded-full"> <StatusBadge status={donation.status} />
{donation.status.charAt(0).toUpperCase() + donation.status.slice(1)} </TableCell>
</Badge> <TableCell>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-brand-purple "> <div className="flex items-center gap-2 text-brand-purple ">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(donation.created_at)} {formatDate(donation.created_at)}
</span> </span>
</div> </div>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif capitalize" }}>
{donation.payment_method || 'N/A'} {donation.payment_method || 'N/A'}
</p> </p>
</td> </TableCell>
<td className="px-6 py-4 text-center"> <TableCell className="text-center">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -486,12 +472,11 @@ const AdminDonations = () => {
> >
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} {isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button> </Button>
</td> </TableCell>
</tr> </TableRow>
{/* Expandable Details Row */}
{isExpanded && ( {isExpanded && (
<tr className="bg-[var(--lavender-400)]/30"> <TableRow className="bg-[var(--lavender-400)]/30">
<td colSpan="7" className="px-6 py-6"> <TableCell colSpan={7} className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Transaction Details Transaction Details
@@ -601,15 +586,15 @@ const AdminDonations = () => {
</div> </div>
</div> </div>
</div> </div>
</td> </TableCell>
</tr> </TableRow>
)} )}
</React.Fragment> </React.Fragment>
); );
}) })
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</Card> </Card>

View File

@@ -191,10 +191,9 @@ const AdminFinancials = () => {
<div className="space-y-4"> <div className="space-y-4">
{reports.map(report => ( {reports.map(report => (
<Card key={report.id} className="p-6"> <Card key={report.id} className="p-6">
<div className="flex items-center gap-6"> <div className="flex items-center gap-3">
<div className="bg-light-lavender p-4 rounded-xl min-w-[100px] text-center"> <div className="bg-light-lavender p-3 rounded-xl self-center">
<DollarSign className="h-6 w-6 mx-auto mb-1" /> <DollarSign className="size-8 " />
<div className="text-2xl font-bold">{report.year}</div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">

View File

@@ -1,48 +1,367 @@
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Card } from '../../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { DEFAULT_MEMBER_TIERS } from '../../config/MemberTiers'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../../components/ui/alert-dialog';
import { toast } from 'sonner';
import { useAuth } from '../../context/AuthContext';
import useMemberTiers from '../../hooks/use-member-tiers';
import { TIER_ICON_OPTIONS, BADGE_COLOR_PRESETS } from '../../config/MemberTiers';
import { getTierIcon } from '../../config/memberTierIcons';
import { Save, RotateCcw, Plus, Trash2, GripVertical, AlertTriangle, Users } from 'lucide-react';
const AdminMemberTiers = () => { const AdminMemberTiers = () => {
const { user } = useAuth();
const { tiers, loading, saving, updateTiers, resetToDefaults } = useMemberTiers({ isAdmin: true });
const [editedTiers, setEditedTiers] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const isSuperAdmin = user?.role === 'superadmin';
// Initialize edited tiers when tiers load
useEffect(() => {
if (tiers && tiers.length > 0) {
setEditedTiers(JSON.parse(JSON.stringify(tiers)));
setHasChanges(false);
}
}, [tiers]);
// Check for changes
useEffect(() => {
if (tiers && editedTiers.length > 0) {
const changed = JSON.stringify(tiers) !== JSON.stringify(editedTiers);
setHasChanges(changed);
}
}, [tiers, editedTiers]);
const handleTierChange = useCallback((index, field, value) => {
setEditedTiers(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
}, []);
const handleAddTier = useCallback(() => {
const newTier = {
id: `tier_${Date.now()}`,
label: 'New Tier',
minYears: editedTiers.length > 0
? Math.max(...editedTiers.map(t => t.maxYears || 0)) + 0.001
: 0,
maxYears: 999,
iconKey: 'star',
badgeClass: 'bg-gray-100 text-gray-800 border-gray-200',
};
setEditedTiers(prev => [...prev, newTier]);
}, [editedTiers]);
const handleRemoveTier = useCallback((index) => {
if (editedTiers.length <= 1) {
toast.error('You must have at least one tier');
return;
}
setEditedTiers(prev => prev.filter((_, i) => i !== index));
}, [editedTiers.length]);
const validateTiers = useCallback(() => {
for (let i = 0; i < editedTiers.length; i++) {
const tier = editedTiers[i];
if (!tier.label?.trim()) {
toast.error(`Tier ${i + 1} must have a label`);
return false;
}
if (tier.minYears < 0) {
toast.error(`Tier "${tier.label}" has invalid minimum years`);
return false;
}
if (tier.maxYears <= tier.minYears) {
toast.error(`Tier "${tier.label}" max years must be greater than min years`);
return false;
}
}
// Check for overlapping ranges
const sorted = [...editedTiers].sort((a, b) => a.minYears - b.minYears);
for (let i = 0; i < sorted.length - 1; i++) {
if (sorted[i].maxYears >= sorted[i + 1].minYears) {
toast.error(`Tier ranges overlap between "${sorted[i].label}" and "${sorted[i + 1].label}"`);
return false;
}
}
return true;
}, [editedTiers]);
const handleSave = async () => {
if (!validateTiers()) return;
const success = await updateTiers(editedTiers);
if (success) {
toast.success('Member tiers saved successfully');
} else {
toast.error('Failed to save member tiers');
}
};
const handleReset = async () => {
const success = await resetToDefaults();
if (success) {
toast.success('Member tiers reset to defaults');
setShowResetDialog(false);
} else {
toast.error('Failed to reset member tiers');
}
};
const handleDiscardChanges = () => {
setEditedTiers(JSON.parse(JSON.stringify(tiers)));
setHasChanges(false);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> {/* Header and Actions */}
<h1 className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
Member Tiers <div>
</h1>
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Configure tier names, time ranges, and badges used in the members directory. Members Tiers
</p> </h1>
<p className="text-muted-foreground">
Configure tier names, time ranges, and badges displayed in the members directory.
</p>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<Button variant="outline" onClick={handleDiscardChanges}>
Discard
</Button>
)}
{isSuperAdmin && (
<Button
variant="outline"
onClick={() => setShowResetDialog(true)}
className="text-destructive hover:text-destructive"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset
</Button>
)}
<Button onClick={handleSave} disabled={saving || !hasChanges}>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div> </div>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> {/* Tier Cards */}
<div className="space-y-4"> <div className="space-y-4">
{DEFAULT_MEMBER_TIERS.map((tier) => { {editedTiers.map((tier, index) => {
const rangeLabel = tier.maxDays == null const IconComponent = getTierIcon(tier.iconKey);
? `${tier.minDays}+ days`
: `${tier.minDays}${tier.maxDays} days`;
return ( return (
<div <Card key={tier.id} className="bg-background">
key={tier.id} <CardContent className="pt-6">
className="flex flex-wrap items-center justify-between gap-4 border border-[var(--neutral-800)] rounded-xl p-4" <div className="flex flex-col lg:flex-row gap-6">
> {/* Drag Handle & Remove */}
<div> <div className="flex lg:flex-col items-center gap-2 lg:pt-6">
<div className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <GripVertical className="h-5 w-5 text-muted-foreground cursor-move" />
{tier.label} <Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTier(index)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={editedTiers.length <= 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
<div className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rangeLabel} {/* Tier Configuration */}
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Label */}
<div className="space-y-2">
<Label htmlFor={`tier-label-${index}`}>Label</Label>
<Input
id={`tier-label-${index}`}
value={tier.label}
onChange={(e) => handleTierChange(index, 'label', e.target.value)}
placeholder="Tier Name"
/>
</div>
{/* Min Years */}
<div className="space-y-2">
<Label htmlFor={`tier-min-${index}`}>Min Years</Label>
<Input
id={`tier-min-${index}`}
type="number"
step="0.001"
min="0"
value={tier.minYears}
onChange={(e) => handleTierChange(index, 'minYears', parseFloat(e.target.value) || 0)}
/>
</div>
{/* Max Years */}
<div className="space-y-2">
<Label htmlFor={`tier-max-${index}`}>Max Years</Label>
<Input
id={`tier-max-${index}`}
type="number"
step="0.001"
min="0"
value={tier.maxYears}
onChange={(e) => handleTierChange(index, 'maxYears', parseFloat(e.target.value) || 0)}
/>
</div>
{/* Icon */}
<div className="space-y-2">
<Label htmlFor={`tier-icon-${index}`}>Icon</Label>
<Select
value={tier.iconKey}
onValueChange={(value) => handleTierChange(index, 'iconKey', value)}
>
<SelectTrigger id={`tier-icon-${index}`}>
<SelectValue placeholder="Select icon" />
</SelectTrigger>
<SelectContent>
{TIER_ICON_OPTIONS.map((option) => {
const OptionIcon = getTierIcon(option.key);
return (
<SelectItem key={option.key} value={option.key}>
<div className="flex items-center gap-2">
<OptionIcon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Badge Color */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`tier-badge-${index}`}>Badge Style</Label>
<Select
value={tier.badgeClass}
onValueChange={(value) => handleTierChange(index, 'badgeClass', value)}
>
<SelectTrigger id={`tier-badge-${index}`}>
<SelectValue placeholder="Select color" />
</SelectTrigger>
<SelectContent>
{BADGE_COLOR_PRESETS.map((preset) => (
<SelectItem key={preset.label} value={preset.badgeClass}>
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${preset.badgeClass}`} />
{preset.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Preview */}
<div className="space-y-2 md:col-span-2 flex flex-col">
<Label>Preview</Label>
<div className="flex-1 flex items-center">
<Badge className={`px-3 py-1.5 rounded-md text-sm flex items-center gap-2 border ${tier.badgeClass}`}>
<IconComponent className="h-4 w-4" />
{tier.label || 'Tier Name'}
</Badge>
</div>
</div>
</div> </div>
</div> </div>
<Badge className={`px-3 py-1 rounded-md text-sm ${tier.badgeClass}`}> </CardContent>
{tier.icon} </Card>
</Badge> );
</div> })}
); </div>
})}
</div> {/* Add Tier Button */}
<Button variant="outline" onClick={handleAddTier} className="w-full border-dashed">
<Plus className="h-4 w-4 mr-2" />
Add Tier
</Button>
{/* Info Card */}
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-5 w-5" />
How Member Tiers Work
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>
Member tiers are automatically assigned based on how long a member has been active.
The tier badge appears on member profiles and in the member directory.
</p>
<ul className="list-disc list-inside space-y-1">
<li>Tiers are matched based on membership duration in years</li>
<li>Each tier should have non-overlapping year ranges</li>
<li>The last tier typically uses a high max value (e.g., 999) to catch all long-term members</li>
</ul>
</CardContent>
</Card> </Card>
{/* Reset Confirmation Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Reset Tiers to Defaults?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all custom tier configurations and restore the default member tiers.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleReset}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Reset to Defaults
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };

View File

@@ -17,7 +17,7 @@ import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircl
import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog'; import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog'; import InviteMemberDialog from '../../components/InviteMemberDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard'; import WordPressImportWizard from '../../components/WordPressImportWizard';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard'; import { StatCard } from '@/components/StatCard';
@@ -323,11 +323,9 @@ const AdminMembers = () => {
<SelectItem value="active">Active</SelectItem> <SelectItem value="active">Active</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem> <SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem> <SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem> <SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem> <SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem> <SelectItem value="expired">Expired</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -523,7 +521,7 @@ const AdminMembers = () => {
onSuccess={refetch} onSuccess={refetch}
/> />
<InviteStaffDialog <InviteMemberDialog
open={inviteDialogOpen} open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen} onOpenChange={setInviteDialogOpen}
onSuccess={refetch} onSuccess={refetch}

View File

@@ -223,10 +223,13 @@ const AdminNewsletters = () => {
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
{year} {year}
</h2> </h2>
<div className="grid gap-4"> <div className="grid gap-3">
{groupedNewsletters[year].map(newsletter => ( {groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6"> <Card key={newsletter.id} className="p-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between ">
<div className="bg-light-lavender p-3 mr-4 rounded-xl self-center">
<FileText className="size-8 " />
</div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
{newsletter.title} {newsletter.title}

File diff suppressed because it is too large Load Diff

View File

@@ -205,16 +205,13 @@ const AdminRoles = () => {
const groupedPermissions = groupPermissionsByModule(); const groupedPermissions = groupPermissionsByModule();
return ( return (
<div className="space-y-6 "> <div className="space-y-6">
{/* Header */} {/* Action Bar */}
<div className="flex justify-between items-center "> <div className="flex justify-between items-center">
<div> <p className="text-muted-foreground">
<h1 className="text-3xl font-bold text-[var(--purple-ink)]">Role Management</h1> Create and manage custom roles with specific permissions
<p className="text-gray-600 mt-1"> </p>
Create and manage custom roles with specific permissions <Button className="btn-lavender" onClick={() => setShowCreateModal(true)}>
</p>
</div>
<Button className="btn-lavender " onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Create Role Create Role
</Button> </Button>

View File

@@ -126,18 +126,7 @@ export default function AdminSettings() {
} }
return ( return (
<div className="container mx-auto p-6 max-w-4xl"> <div className="space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<SettingsIcon className="h-8 w-8 text-brand-purple" />
Settings
</h1>
<p className="text-gray-600 mt-2">
Manage system configuration and integrations
</p>
</div>
{/* Stripe Integration Card */} {/* Stripe Integration Card */}
<Card className="border-2 border-[var(--lavender-200)] shadow-sm"> <Card className="border-2 border-[var(--lavender-200)] shadow-sm">
<CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]"> <CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,743 @@
import React, { useEffect, useState, useCallback } from 'react';
import api from '../../utils/api';
import { useAuth } from '../../context/AuthContext';
import { useThemeConfig } from '../../context/ThemeConfigContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Textarea } from '../../components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../../components/ui/alert-dialog';
import { toast } from 'sonner';
import { Palette, Upload, Trash2, RotateCcw, Save, Image, Globe, AlertTriangle } from 'lucide-react';
const AdminTheme = () => {
const { user } = useAuth();
const { refreshTheme, DEFAULT_THEME } = useThemeConfig();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingFavicon, setUploadingFavicon] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const [themeData, setThemeData] = useState({
site_name: '',
site_short_name: '',
site_description: '',
logo_url: null,
favicon_url: null,
colors: {
primary: '280 47% 27%',
primary_foreground: '0 0% 100%',
accent: '24 86% 55%',
brand_purple: '256 35% 47%',
brand_orange: '24 86% 55%',
brand_lavender: '262 46% 80%'
},
meta_theme_color: '#664fa3'
});
const [originalData, setOriginalData] = useState(null);
const [metadata, setMetadata] = useState({
is_default: true,
updated_at: null,
updated_by: null
});
const isSuperAdmin = user?.role === 'superadmin';
const fetchThemeSettings = useCallback(async () => {
try {
setLoading(true);
const response = await api.get('/admin/settings/theme');
const { config, is_default, updated_at, updated_by } = response.data;
setThemeData(config);
setOriginalData(config);
setMetadata({ is_default, updated_at, updated_by });
} catch (error) {
toast.error('Failed to fetch theme settings');
console.error('Fetch theme error:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchThemeSettings();
}, [fetchThemeSettings]);
const handleInputChange = (field, value) => {
setThemeData(prev => ({
...prev,
[field]: value
}));
};
const handleColorChange = (colorKey, value) => {
setThemeData(prev => ({
...prev,
colors: {
...prev.colors,
[colorKey]: value
}
}));
};
const handleSaveSettings = async () => {
try {
setSaving(true);
// Build update payload with only changed fields
const payload = {};
if (themeData.site_name !== originalData?.site_name) {
payload.site_name = themeData.site_name;
}
if (themeData.site_short_name !== originalData?.site_short_name) {
payload.site_short_name = themeData.site_short_name;
}
if (themeData.site_description !== originalData?.site_description) {
payload.site_description = themeData.site_description;
}
if (JSON.stringify(themeData.colors) !== JSON.stringify(originalData?.colors)) {
payload.colors = themeData.colors;
}
if (themeData.meta_theme_color !== originalData?.meta_theme_color) {
payload.meta_theme_color = themeData.meta_theme_color;
}
if (Object.keys(payload).length === 0) {
toast.info('No changes to save');
return;
}
await api.put('/admin/settings/theme', payload);
toast.success('Theme settings saved successfully');
// Refresh theme context and re-fetch settings
await refreshTheme();
await fetchThemeSettings();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save theme settings');
console.error('Save theme error:', error);
} finally {
setSaving(false);
}
};
const handleLogoUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
if (!allowedTypes.includes(file.type)) {
toast.error('Invalid file type. Please upload PNG, JPEG, WebP, or SVG.');
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('File too large. Maximum size is 5MB.');
return;
}
try {
setUploadingLogo(true);
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/admin/settings/theme/logo', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setThemeData(prev => ({
...prev,
logo_url: response.data.logo_url
}));
toast.success('Logo uploaded successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to upload logo');
console.error('Upload logo error:', error);
} finally {
setUploadingLogo(false);
// Reset the input
event.target.value = '';
}
};
const handleFaviconUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml'];
if (!allowedTypes.includes(file.type)) {
toast.error('Invalid file type. Please upload ICO, PNG, or SVG.');
return;
}
// Validate file size (1MB)
if (file.size > 1 * 1024 * 1024) {
toast.error('File too large. Maximum size is 1MB.');
return;
}
try {
setUploadingFavicon(true);
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/admin/settings/theme/favicon', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setThemeData(prev => ({
...prev,
favicon_url: response.data.favicon_url
}));
toast.success('Favicon uploaded successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to upload favicon');
console.error('Upload favicon error:', error);
} finally {
setUploadingFavicon(false);
// Reset the input
event.target.value = '';
}
};
const handleDeleteLogo = async () => {
try {
await api.delete('/admin/settings/theme/logo');
setThemeData(prev => ({
...prev,
logo_url: null
}));
toast.success('Logo deleted successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete logo');
}
};
const handleDeleteFavicon = async () => {
try {
await api.delete('/admin/settings/theme/favicon');
setThemeData(prev => ({
...prev,
favicon_url: null
}));
toast.success('Favicon deleted successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete favicon');
}
};
const handleResetToDefaults = async () => {
try {
await api.post('/admin/settings/theme/reset');
toast.success('Theme reset to defaults');
setShowResetDialog(false);
await refreshTheme();
await fetchThemeSettings();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to reset theme');
}
};
// Convert HSL string to approximate hex for color picker
const hslToHex = (hslString) => {
if (!hslString) return '#000000';
const parts = hslString.split(' ');
if (parts.length !== 3) return '#000000';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1]) / 100;
const l = parseFloat(parts[2]) / 100;
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
const toHex = (x) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Convert hex to HSL string
const hexToHsl = (hex) => {
if (!hex) return '0 0% 0%';
// Remove # if present
hex = hex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
default: h = 0;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
const colorFields = [
{ key: 'primary', label: 'Primary Color', description: 'Main brand color used for buttons and highlights' },
{ key: 'primary_foreground', label: 'Primary Foreground', description: 'Text color on primary backgrounds' },
{ key: 'accent', label: 'Accent Color', description: 'Secondary highlight color' },
{ key: 'brand_purple', label: 'Brand Purple', description: 'Purple brand color' },
{ key: 'brand_orange', label: 'Brand Orange', description: 'Orange brand color' },
{ key: 'brand_lavender', label: 'Brand Lavender', description: 'Lavender brand color' }
];
return (
<div className="space-y-6">
{/* Action Buttons */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
Customize the appearance of your membership site
</p>
<div className="flex items-center gap-2">
{isSuperAdmin && (
<Button
variant="outline"
onClick={() => setShowResetDialog(true)}
className="text-destructive hover:text-destructive"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</Button>
)}
<Button onClick={handleSaveSettings} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{/* Metadata Banner */}
{!metadata.is_default && metadata.updated_at && (
<div className="bg-muted/50 border rounded-lg px-4 py-3 text-sm text-muted-foreground">
Last updated {new Date(metadata.updated_at).toLocaleDateString()}
{metadata.updated_by && ` by ${metadata.updated_by}`}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Branding Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Branding
</CardTitle>
<CardDescription>
Configure your site name and brand identity
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Site Name */}
<div className="space-y-2">
<Label htmlFor="site_name">Site Name</Label>
<Input
id="site_name"
value={themeData.site_name}
onChange={(e) => handleInputChange('site_name', e.target.value)}
placeholder="LOAF - Lesbians Over Age Fifty"
maxLength={200}
/>
<p className="text-xs text-muted-foreground">
Displayed in the browser title and navigation
</p>
</div>
{/* Short Name */}
<div className="space-y-2">
<Label htmlFor="site_short_name">Short Name</Label>
<Input
id="site_short_name"
value={themeData.site_short_name}
onChange={(e) => handleInputChange('site_short_name', e.target.value)}
placeholder="LOAF"
maxLength={50}
/>
<p className="text-xs text-muted-foreground">
Used for PWA home screen icon label
</p>
</div>
{/* Site Description */}
<div className="space-y-2">
<Label htmlFor="site_description">Site Description</Label>
<Textarea
id="site_description"
value={themeData.site_description}
onChange={(e) => handleInputChange('site_description', e.target.value)}
placeholder="A community organization for lesbians over age fifty..."
maxLength={500}
rows={3}
/>
<p className="text-xs text-muted-foreground">
Used for SEO meta description tag ({themeData.site_description?.length || 0}/500)
</p>
</div>
{/* Meta Theme Color */}
<div className="space-y-2">
<Label htmlFor="meta_theme_color">Browser Theme Color</Label>
<div className="flex items-center gap-2">
<input
type="color"
id="meta_theme_color"
value={themeData.meta_theme_color}
onChange={(e) => handleInputChange('meta_theme_color', e.target.value)}
className="h-10 w-14 rounded border cursor-pointer"
/>
<Input
value={themeData.meta_theme_color}
onChange={(e) => handleInputChange('meta_theme_color', e.target.value)}
placeholder="#664fa3"
className="flex-1"
maxLength={7}
/>
</div>
<p className="text-xs text-muted-foreground">
Color shown in mobile browser address bar (PWA)
</p>
</div>
</CardContent>
</Card>
{/* Logo & Favicon Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Image className="h-5 w-5" />
Logo & Favicon
</CardTitle>
<CardDescription>
Upload your organization's logo and favicon
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Logo Upload */}
<div className="space-y-3">
<Label>Logo</Label>
<div className="flex items-start gap-4">
<div className="w-24 h-24 bg-muted rounded-lg flex items-center justify-center overflow-hidden border">
{themeData.logo_url ? (
<img
src={themeData.logo_url}
alt="Logo"
className="w-full h-full object-contain"
/>
) : (
<Image className="h-10 w-10 text-muted-foreground" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
onChange={handleLogoUpload}
className="hidden"
disabled={uploadingLogo}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingLogo}
asChild
>
<span>
<Upload className="h-4 w-4 mr-2" />
{uploadingLogo ? 'Uploading...' : 'Upload'}
</span>
</Button>
</label>
{themeData.logo_url && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDeleteLogo}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPEG, WebP, or SVG. Max 5MB.
</p>
</div>
</div>
</div>
{/* Favicon Upload */}
<div className="space-y-3">
<Label>Favicon</Label>
<div className="flex items-start gap-4">
<div className="w-16 h-16 bg-muted rounded-lg flex items-center justify-center overflow-hidden border">
{themeData.favicon_url ? (
<img
src={themeData.favicon_url}
alt="Favicon"
className="w-8 h-8 object-contain"
/>
) : (
<Globe className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/x-icon,image/vnd.microsoft.icon,image/png,image/svg+xml"
onChange={handleFaviconUpload}
className="hidden"
disabled={uploadingFavicon}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingFavicon}
asChild
>
<span>
<Upload className="h-4 w-4 mr-2" />
{uploadingFavicon ? 'Uploading...' : 'Upload'}
</span>
</Button>
</label>
{themeData.favicon_url && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDeleteFavicon}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
ICO, PNG, or SVG. Max 1MB.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Color Scheme Section */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Color Scheme
</CardTitle>
<CardDescription>
Customize the color palette used throughout the site. Colors are stored as HSL values.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{colorFields.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`color_${field.key}`}>{field.label}</Label>
<div className="flex items-center gap-2">
<input
type="color"
id={`color_${field.key}`}
value={hslToHex(themeData.colors[field.key])}
onChange={(e) => handleColorChange(field.key, hexToHsl(e.target.value))}
className="h-10 w-14 rounded border cursor-pointer"
/>
<Input
value={themeData.colors[field.key]}
onChange={(e) => handleColorChange(field.key, e.target.value)}
placeholder="280 47% 27%"
className="flex-1 font-mono text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">
{field.description}
</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Preview Section */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
See how your theme changes will look
</CardDescription>
</CardHeader>
<CardContent>
<div
className="rounded-lg border p-6 space-y-4"
style={{
'--preview-primary': themeData.colors.primary,
'--preview-accent': themeData.colors.accent,
'--preview-brand-purple': themeData.colors.brand_purple,
'--preview-brand-orange': themeData.colors.brand_orange,
'--preview-brand-lavender': themeData.colors.brand_lavender,
}}
>
<div className="flex items-center gap-4">
{themeData.logo_url ? (
<img src={themeData.logo_url} alt="Preview Logo" className="h-16 w-16 object-contain" />
) : (
<div className="h-16 w-16 bg-muted rounded-lg flex items-center justify-center">
<Image className="h-8 w-8 text-muted-foreground" />
</div>
)}
<div>
<h3 className="text-xl font-bold">{themeData.site_name || 'Site Name'}</h3>
<p className="text-sm text-muted-foreground">{themeData.site_short_name || 'Short Name'}</p>
{themeData.site_description && (
<p className="text-xs text-muted-foreground mt-1 max-w-md">{themeData.site_description}</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 pt-4">
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.primary})` }}
>
Primary
</div>
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.accent})` }}
>
Accent
</div>
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_purple})` }}
>
Brand Purple
</div>
<div
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_orange})` }}
>
Brand Orange
</div>
<div
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_lavender})` }}
>
Brand Lavender
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Reset Confirmation Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Reset Theme to Defaults?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all custom theme settings including uploaded logo and favicon.
The site will revert to the default LOAF theme. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleResetToDefaults}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Reset to Defaults
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default AdminTheme;

View File

@@ -11,6 +11,8 @@ import { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog'; 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 AdminPaymentMethodsPanel from '../../components/admin/AdminPaymentMethodsPanel';
const AdminUserView = () => { const AdminUserView = () => {
const { userId } = useParams(); const { userId } = useParams();
@@ -19,8 +21,8 @@ const AdminUserView = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [resendVerificationLoading, setResendVerificationLoading] = useState(false); const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
const [subscriptions, setSubscriptions] = useState([]); const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true); const [transactionsLoading, setTransactionsLoading] = useState(true);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingAction, setPendingAction] = useState(null); const [pendingAction, setPendingAction] = useState(null);
const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false);
@@ -69,7 +71,7 @@ const AdminUserView = () => {
useEffect(() => { useEffect(() => {
fetchConfig(); fetchConfig();
fetchUserProfile(); fetchUserProfile();
fetchSubscriptions(); fetchTransactions();
}, [userId]); }, [userId]);
useEffect(() => { useEffect(() => {
@@ -90,14 +92,15 @@ const AdminUserView = () => {
} }
}; };
const fetchSubscriptions = async () => { const fetchTransactions = async () => {
try { try {
const response = await api.get(`/admin/subscriptions?user_id=${userId}`); setTransactionsLoading(true);
setSubscriptions(response.data); const response = await api.get(`/admin/users/${userId}/transactions`);
setTransactions(response.data);
} catch (error) { } catch (error) {
console.error('Failed to fetch subscriptions:', error); console.error('Failed to fetch transactions:', error);
} finally { } finally {
setSubscriptionsLoading(false); setTransactionsLoading(false);
} }
}; };
@@ -415,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" }}>
@@ -484,91 +495,17 @@ const AdminUserView = () => {
</div> </div>
</Card> </Card>
{/* Subscription Info (if applicable) */} {/* Transaction History */}
{user.role === 'member' && ( <div className="mt-8">
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] mt-8"> <TransactionHistory
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}> subscriptions={transactions.subscriptions}
Subscription Information donations={transactions.donations}
</h2> totalSubscriptionCents={transactions.total_subscription_amount_cents}
totalDonationCents={transactions.total_donation_amount_cents}
{subscriptionsLoading ? ( loading={transactionsLoading}
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading subscriptions...</p> isAdmin={true}
) : subscriptions.length === 0 ? ( />
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No subscriptions found for this member.</p> </div>
) : (
<div className="space-y-6">
{subscriptions.map((sub) => (
<div key={sub.id} className="p-6 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.plan.name}
</h3>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.plan.billing_cycle}
</p>
</div>
<StatusBadge status={sub.status} />
</div>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDateDisplayValue(sub.start_date)}
</p>
</div>
{sub.end_date && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDateDisplayValue(sub.end_date)}
</p>
</div>
)}
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Base Amount</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.base_subscription_cents / 100).toFixed(2)}
</p>
</div>
{sub.donation_cents > 0 && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Donation</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.donation_cents / 100).toFixed(2)}
</p>
</div>
)}
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Paid</label>
<p className="text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.amount_paid_cents / 100).toFixed(2)}
</p>
</div>
{sub.payment_method && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.payment_method}
</p>
</div>
)}
{sub.stripe_subscription_id && (
<div className="md:col-span-2">
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Stripe Subscription ID</label>
<p className="text-[var(--purple-ink)] text-xs font-mono" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.stripe_subscription_id}
</p>
</div>
)}
</div>
</div>
))}
</div>
)}
</Card>
)}
{/* Admin Action Confirmation Dialog */} {/* Admin Action Confirmation Dialog */}
<ConfirmationDialog <ConfirmationDialog

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
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 { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { import {
Select, Select,
@@ -19,6 +18,12 @@ import {
TableRow, TableRow,
TableCell, TableCell,
} from '../../components/ui/table'; } from '../../components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../../components/ui/tooltip';
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@@ -29,11 +34,27 @@ 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 } 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';
import StatusBadge from '@/components/StatusBadge'; import StatusBadge from '@/components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { Button } from '@/components/ui/button';
import ViewRegistrationDialog from '@/components/ViewRegistrationDialog';
const AdminValidations = () => { const AdminValidations = () => {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -47,6 +68,8 @@ const AdminValidations = () => {
const [pendingAction, setPendingAction] = useState(null); const [pendingAction, setPendingAction] = useState(null);
const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false); const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false);
const [userToReject, setUserToReject] = useState(null); const [userToReject, setUserToReject] = useState(null);
const [viewRegistrationDialogOpen, setViewRegistrationDialogOpen] = useState(false);
const [selectedUserForView, setSelectedUserForView] = useState(null);
// Filtering state // Filtering state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -60,6 +83,9 @@ const AdminValidations = () => {
const [sortBy, setSortBy] = useState('created_at'); const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc'); const [sortOrder, setSortOrder] = useState('desc');
// Resend email state
const [resendLoading, setResendLoading] = useState(null);
useEffect(() => { useEffect(() => {
fetchPendingUsers(); fetchPendingUsers();
}, []); }, []);
@@ -235,6 +261,24 @@ const AdminValidations = () => {
} }
}; };
const handleRegistrationDialog = (user) => {
setSelectedUserForView(user);
setViewRegistrationDialogOpen(true);
};
// Resend Email Handler
const handleResendVerification = async (user) => {
setResendLoading(user.id);
try {
await api.post(`/admin/users/${user.id}/resend-verification`);
toast.success(`Verification email sent to ${user.email}`);
fetchPendingUsers();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to send verification email');
} finally {
setResendLoading(null);
}
};
const handleSort = (column) => { const handleSort = (column) => {
@@ -260,6 +304,37 @@ const AdminValidations = () => {
<ArrowDown className="h-4 w-4 inline ml-1" />; <ArrowDown className="h-4 w-4 inline ml-1" />;
}; };
const formatPhoneNumber = (phone) => {
if (!phone) return '-';
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)} - ${cleaned.slice(6)}`;
}
return phone;
};
const handleActionSelect = (user, action) => {
switch (action) {
case 'validate':
handleValidateRequest(user);
break;
case 'bypass_validate':
handleBypassAndValidateRequest(user);
break;
case 'resend_email':
handleResendVerification(user);
break;
case 'activate_payment':
handleActivatePayment(user);
break;
case 'reactivate':
handleReactivateUser(user);
break;
default:
break;
}
};
return ( return (
<> <>
{/* Header */} {/* Header */}
@@ -272,45 +347,54 @@ const AdminValidations = () => {
</p> </p>
</div> </div>
{/* Stats Card */} {/* Stats Card */}
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8"> <Card className="rounded-3xl bg-brand-lavender/10 p-8 mb-8">
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
Quick Overview
</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">
<div>
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Pending</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <StatCard
{pendingUsers.length} title="Awaiting Email"
</p> value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
</div> icon={Mail}
<div> iconBgClass="text-brand-pink"
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Awaiting Email</p> dataTestId="stat-total-users"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> />
{pendingUsers.filter(u => u.status === 'pending_email').length}
</p> <StatCard
</div> title="Pending Validation"
<div> value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p> icon={ShieldCheck}
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> iconBgClass="text-success"
{pendingUsers.filter(u => u.status === 'pending_validation').length} dataTestId="stat-pending-validation"
</p> />
</div>
<div> <StatCard
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p> title="Payment Pending"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
{pendingUsers.filter(u => u.status === 'pre_validated').length} icon={CreditCard}
</p> iconBgClass="text-accent"
</div> dataTestId="stat-payment-pending"
<div> />
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <StatCard
{pendingUsers.filter(u => u.status === 'payment_pending').length} title="Rejected"
</p> value={loading ? '-' : pendingUsers.filter(u => u.status === 'rejected').length}
</div> icon={XCircle}
<div> iconBgClass="text-red-600"
<p className="text-sm text-red-600 mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Rejected</p> dataTestId="stat-rejected"
<p className="text-3xl font-semibold text-red-800" style={{ fontFamily: "'Inter', sans-serif" }}> />
{pendingUsers.filter(u => u.status === 'rejected').length}
</p> <StatCard
</div> 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>
@@ -331,13 +415,12 @@ const AdminValidations = () => {
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]"> <SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Filter by status" /> <SelectValue placeholder="Filter by status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="">
<SelectItem value="all">All Statuses</SelectItem> <SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Awaiting Email</SelectItem> <SelectItem value="pending_email" >Awaiting Email</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem> <SelectItem value="pending_validation" >Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem> <SelectItem value="payment_pending" >Payment Pending</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem> <SelectItem value="rejected" >Rejected</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -353,14 +436,13 @@ const AdminValidations = () => {
<Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden"> <Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="text-md">
<TableHead <TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20" className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
onClick={() => handleSort('first_name')} onClick={() => handleSort('first_name')}
> >
Name {renderSortIcon('first_name')} Member {renderSortIcon('first_name')}
</TableHead> </TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead> <TableHead>Phone</TableHead>
<TableHead <TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20" className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
@@ -374,6 +456,13 @@ const AdminValidations = () => {
> >
Registered {renderSortIcon('created_at')} Registered {renderSortIcon('created_at')}
</TableHead> </TableHead>
<TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
onClick={() => handleSort('email_verification_expires_at')}
>
{/* TODO: change ' ' */}
Validation Expiry {renderSortIcon('email_verification_expires_at')}
</TableHead>
<TableHead>Referred By</TableHead> <TableHead>Referred By</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
@@ -381,105 +470,116 @@ const AdminValidations = () => {
<TableBody> <TableBody>
{paginatedUsers.map((user) => ( {paginatedUsers.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell className="font-medium"> <TableCell className=" ">
{user.first_name} {user.last_name} <div className='font-semibold'>
{user.first_name} {user.last_name}
</div>
<div className='text-brand-purple'>
{user.email}
</div>
</TableCell> </TableCell>
<TableCell>{user.email}</TableCell> <TableCell>{formatPhoneNumber(user.phone)}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell><StatusBadge status={user.status} /></TableCell> <TableCell><StatusBadge status={user.status} /></TableCell>
<TableCell> <TableCell>
{new Date(user.created_at).toLocaleDateString()} {new Date(user.created_at).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell>
{user.email_verification_expires_at
? new Date(user.email_verification_expires_at).toLocaleString()
: '—'}
</TableCell>
<TableCell> <TableCell>
{user.referred_by_member_name || '-'} {user.referred_by_member_name || '-'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-2"> <div className='flex gap-2 justify-between'>
{user.status === 'rejected' ? (
<Button <Select
onClick={() => handleReactivateUser(user)} value=""
disabled={actionLoading === user.id} onValueChange={(action) => handleActionSelect(user, action)}
size="sm" disabled={actionLoading === user.id || resendLoading === user.id}
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]" >
> <SelectTrigger className="w-[100px] h-9 border-[var(--neutral-800)]">
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'} <SelectValue placeholder={actionLoading === user.id || resendLoading === user.id ? 'Processing...' : 'Action'} />
</Button> </SelectTrigger>
) : user.status === 'pending_email' ? ( <SelectContent>
<> {user.status === 'rejected' ? (
{hasPermission('users.approve') && ( <SelectItem value="reactivate">Reactivate</SelectItem>
<Button ) : user.status === 'pending_email' ? (
onClick={() => handleBypassAndValidateRequest(user)} <>
disabled={actionLoading === user.id} {hasPermission('users.approve') && (
size="sm" <SelectItem value="bypass_validate">Bypass & Validate</SelectItem>
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background" )}
> {hasPermission('users.approve') && (
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'} <SelectItem value="resend_email">Resend Email</SelectItem>
</Button> )}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
) : user.status === 'payment_pending' ? (
<>
{hasPermission('subscriptions.activate') && (
<SelectItem value="activate_payment">Activate Payment</SelectItem>
)}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
) : (
<>
{hasPermission('users.approve') && (
<SelectItem value="validate">Validate</SelectItem>
)}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
)} )}
{hasPermission('users.approve') && ( </SelectContent>
</Select>
<TooltipProvider>
{/* view registration */}
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
onClick={() => handleRejectUser(user)} onClick={() => handleRegistrationDialog(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
size="sm" size="sm"
variant="outline" variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10" className="border-2 border-primary text-primary hover:bg-red-50 dark:hover:bg-red-500/10"
> >
<X className="h-4 w-4 mr-1" /> <FileText className="size-4" />
Reject
</Button> </Button>
)} </TooltipTrigger>
</> <TooltipContent>
) : user.status === 'payment_pending' ? ( View registration
<> </TooltipContent>
{hasPermission('subscriptions.activate') && ( </Tooltip>
<Button
onClick={() => handleActivatePayment(user)} {/* reject */}
size="sm" {hasPermission('users.approve') && (
className="btn-light-lavender" <Tooltip>
> <TooltipTrigger asChild>
<CheckCircle className="h-4 w-4 mr-1" /> <Button
Activate Payment onClick={() => handleRejectUser(user)}
</Button> disabled={actionLoading === user.id}
)} size="sm"
{hasPermission('users.approve') && ( variant="outline"
<Button className="border-2 mr-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
onClick={() => handleRejectUser(user)} >
disabled={actionLoading === user.id} X
size="sm" </Button>
variant="outline" </TooltipTrigger>
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10" <TooltipContent>
> Reject user
<X className="h-4 w-4 mr-1" /> </TooltipContent>
Reject </Tooltip>
</Button> )}
)} </TooltipProvider>
</>
) : (
<>
{hasPermission('users.approve') && (
<Button
onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]"
>
{actionLoading === user.id ? 'Validating...' : 'Validate'}
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -604,6 +704,13 @@ const AdminValidations = () => {
user={userToReject} user={userToReject}
loading={actionLoading !== null} loading={actionLoading !== null}
/> />
{/* View Registration Dialog */}
<ViewRegistrationDialog
open={viewRegistrationDialogOpen}
onOpenChange={setViewRegistrationDialogOpen}
user={selectedUserForView}
/>
</> </>
); );
}; };

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';
@@ -15,9 +16,10 @@ import {
} from '../../components/ui/dialog'; } from '../../components/ui/dialog';
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react'; import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import StatusBadge from '@/components/StatusBadge';
import MemberCard from '../../components/MemberCard'; import MemberCard from '../../components/MemberCard';
import MemberBadge from '../../components/MemberBadge';
import useMembers from '../../hooks/use-members'; import useMembers from '../../hooks/use-members';
import useMemberTiers from '../../hooks/use-member-tiers';
const MembersDirectory = () => { const MembersDirectory = () => {
const [selectedMember, setSelectedMember] = useState(null); const [selectedMember, setSelectedMember] = useState(null);
@@ -25,7 +27,27 @@ const MembersDirectory = () => {
const { toast } = useToast(); const { toast } = useToast();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12; const pageSize = 12;
const { tiers } = useMemberTiers();
const allowedRoles = useMemo(() => [], []); const allowedRoles = useMemo(() => [], []);
const normalizeStatus = useCallback((status) => {
if (typeof status === 'string') {
return status.toLowerCase();
}
return status;
}, []);
const normalizeMembers = useCallback(
(data) => {
const list = Array.isArray(data)
? data
: data?.members || data?.results || data?.items || data?.data || [];
return list.map((member) => ({
...member,
status: normalizeStatus(member.status ?? member.membership_status ?? member.member_status),
}));
},
[normalizeStatus]
);
const searchAccessor = useCallback( const searchAccessor = useCallback(
(member) => [ (member) => [
`${member.first_name} ${member.last_name}`, `${member.first_name} ${member.last_name}`,
@@ -47,12 +69,14 @@ const MembersDirectory = () => {
loading, loading,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
filterValue,
} = useMembers({ } = useMembers({
endpoint: '/members/directory', endpoint: '/members/directory',
initialFilter: 'active', initialFilter: 'all',
filterKey: 'status', filterKey: 'status',
allowedRoles, allowedRoles,
searchAccessor, searchAccessor,
transform: normalizeMembers,
fetchErrorMessage: 'Failed to load members directory. Please try again.', fetchErrorMessage: 'Failed to load members directory. Please try again.',
onFetchError: handleFetchError onFetchError: handleFetchError
}); });
@@ -67,7 +91,12 @@ const MembersDirectory = () => {
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize); const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
const totalMembers = members.length; const totalMembers = useMemo(() => {
if (!filterValue || filterValue === 'all') {
return members.length;
}
return members.filter((member) => member.status === filterValue).length;
}, [members, filterValue]);
@@ -107,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">
@@ -126,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
@@ -158,7 +187,7 @@ const MembersDirectory = () => {
) : filteredMembers.length > 0 ? ( ) : filteredMembers.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{paginatedMembers.map((member) => ( {paginatedMembers.map((member) => (
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} /> <MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} tiers={tiers} />
))} ))}
</div> </div>
) : ( ) : (
@@ -193,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>
@@ -210,7 +240,7 @@ const MembersDirectory = () => {
<DialogHeader> <DialogHeader>
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedMember.first_name} {selectedMember.last_name} {selectedMember.first_name} {selectedMember.last_name}
<StatusBadge status={selectedMember.membership_status || selectedMember.status} /> <MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
</DialogTitle> </DialogTitle>
{selectedMember.directory_partner_name && ( {selectedMember.directory_partner_name && (
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>

View File

@@ -167,9 +167,9 @@ export default function NewsletterArchive() {
{groupedNewsletters[year].map(newsletter => ( {groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow"> <Card key={newsletter.id} className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl"> <div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl">
<FileText className="h-8 w-8 text-white" /> <FileText className="h-8 w-8 text-white" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{newsletter.title} {newsletter.title}

View File

@@ -2,32 +2,6 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%;
--foreground: 280 47% 27%;
--card: 0 0% 100%;
--card-foreground: 280 47% 27%;
--popover: 0 0% 100%;
--popover-foreground: 280 47% 27%;
--primary: 280 47% 27%;
--primary-foreground: 0 0% 100%;
--secondary: 268 33% 89%;
--secondary-foreground: 280 47% 27%;
--muted: 268 43% 95%;
--muted-foreground: 268 35% 47%;
--accent: var(--brand-orange);
--accent-foreground: 280 47% 27%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 268 33% 89%;
--input: 268 33% 89%;
--ring: 268 35% 47%;
--chart-1: 268 36% 46%;
--chart-2: 17 100% 73%;
--chart-3: 268 33% 89%;
--chart-4: 280 44% 29%;
--chart-5: 268 35% 47%;
--radius: 0.5rem;
/* ========================= /* =========================
Brand Colors Brand Colors
========================= */ ========================= */
@@ -47,7 +21,7 @@
/* /*
========================== ==========================
Color Patch Social Media Colors
========================== ==========================
*/ */
@@ -55,6 +29,50 @@
--blue-facebook: #1877f2; --blue-facebook: #1877f2;
--blue-twitter: #1da1f2; --blue-twitter: #1da1f2;
--red-instagram: #e4405f; --red-instagram: #e4405f;
/* =========================
Theme Colors
========================= */
--background: 0 0% 100%;
--foreground: 280 47% 27%;
--card: 0 0% 100%;
--card-foreground: 280 47% 27%;
--popover: 0 0% 100%;
--popover-foreground: 280 47% 27%;
--primary: 280 47% 27%;
--primary-foreground: 0 0% 100%;
--secondary: var(--brand-lavender);
--secondary-foreground: 280 47% 27%;
--muted: 268 43% 95%;
--muted-foreground: 268 35% 47%;
--accent: var(--brand-orange);
--accent-foreground: 280 47% 27%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--success: 147 23% 46%;
--success-foreground: 0 0% 98%;
--warning: var(--brand-orange);
--warning-foreground: 0 0% 10%;
--border: 268 33% 89%;
--input: 268 33% 89%;
--ring: 268 35% 47%;
--chart-1: 268 36% 46%;
--chart-2: 17 100% 73%;
--chart-3: 268 33% 89%;
--chart-4: 280 44% 29%;
--chart-5: 268 35% 47%;
--radius: 0.5rem;
--purple-ink: #422268; --purple-ink: #422268;
--purple-ink-2: #422268; --purple-ink-2: #422268;
--purple-deep: #48286e; --purple-deep: #48286e;

View File

@@ -1,23 +1,56 @@
// src/utils/member-tiers.js // src/utils/member-tiers.js
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { DEFAULT_MEMBER_TIERS } from '../config/memberTiers'; import { DEFAULT_MEMBER_TIERS } from '../config/MemberTiers';
export const getTenureDays = (memberSince) => { /**
* Calculate tenure in years (with decimal precision)
* @param {string|Date} memberSince - The date the member joined
* @returns {number|null} - Years of membership or null if invalid
*/
export const getTenureYears = (memberSince) => {
if (!memberSince) return null; if (!memberSince) return null;
const since = new Date(memberSince); const since = new Date(memberSince);
if (Number.isNaN(since.getTime())) return null; if (Number.isNaN(since.getTime())) return null;
return Math.max(0, differenceInDays(new Date(), since));
const now = new Date();
const days = differenceInDays(now, since);
// Convert to years with decimal precision
return Math.max(0, days / 365.25);
}; };
/**
* Get the tier for a member based on their membership duration
* @param {string|Date} memberSince - The date the member joined
* @param {Array} tiers - Array of tier configurations
* @returns {Object} - The matching tier object
*/
export const getTierForMember = (memberSince, tiers = DEFAULT_MEMBER_TIERS) => { export const getTierForMember = (memberSince, tiers = DEFAULT_MEMBER_TIERS) => {
const days = getTenureDays(memberSince); const years = getTenureYears(memberSince);
if (days == null) return tiers[0]; if (years == null) return tiers[0];
const match = tiers.find( const match = tiers.find(
(tier) => (tier) =>
days >= tier.minDays && years >= tier.minYears &&
(tier.maxDays == null || days <= tier.maxDays) (tier.maxYears == null || years <= tier.maxYears)
); );
return match || tiers[0]; return match || tiers[0];
}; };
/**
* Format tenure for display
* @param {string|Date} memberSince - The date the member joined
* @returns {string} - Human-readable tenure string
*/
export const formatTenure = (memberSince) => {
const years = getTenureYears(memberSince);
if (years == null) return 'Unknown';
if (years < 1) {
const months = Math.floor(years * 12);
return months <= 1 ? '< 1 month' : `${months} months`;
}
const wholeYears = Math.floor(years);
return wholeYears === 1 ? '1 year' : `${wholeYears} years`;
};

View File

@@ -49,6 +49,10 @@ module.exports = {
DEFAULT: 'hsl(var(--success))', DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))' foreground: 'hsl(var(--success-foreground))'
}, },
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))'
},
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',