diff --git a/src/components/CreateSubscriptionDialog.js b/src/components/CreateSubscriptionDialog.js
new file mode 100644
index 0000000..9c4a5d5
--- /dev/null
+++ b/src/components/CreateSubscriptionDialog.js
@@ -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 (
+
+ );
+};
+
+export default CreateSubscriptionDialog;
diff --git a/src/components/InviteMemberDialog.js b/src/components/InviteMemberDialog.js
new file mode 100644
index 0000000..bffd573
--- /dev/null
+++ b/src/components/InviteMemberDialog.js
@@ -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 (
+
+ );
+};
+
+export default InviteMemberDialog;
diff --git a/src/components/ViewRegistrationDialog.js b/src/components/ViewRegistrationDialog.js
new file mode 100644
index 0000000..8f02dcf
--- /dev/null
+++ b/src/components/ViewRegistrationDialog.js
@@ -0,0 +1,172 @@
+import React from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from './ui/dialog';
+import { Button } from './ui/button';
+import { Card } from './ui/card';
+import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react';
+import StatusBadge from './StatusBadge';
+
+const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
+ if (!user) return null;
+
+ 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 InfoRow = ({ icon: Icon, label, value }) => (
+
+
+
+
+
+
+ {label}
+
+
+ {value || '—'}
+
+
+
+ );
+
+ return (
+
+ );
+};
+
+export default ViewRegistrationDialog;
diff --git a/src/components/ui/select.jsx b/src/components/ui/select.jsx
index 93913ea..78ae299 100644
--- a/src/components/ui/select.jsx
+++ b/src/components/ui/select.jsx
@@ -83,16 +83,16 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
-
+
- {children}
+ {children}
))
SelectItem.displayName = SelectPrimitive.Item.displayName
diff --git a/src/pages/admin/AdminMembers.js b/src/pages/admin/AdminMembers.js
index 6ee074b..a4b4e7a 100644
--- a/src/pages/admin/AdminMembers.js
+++ b/src/pages/admin/AdminMembers.js
@@ -17,7 +17,7 @@ import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircl
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog';
-import InviteStaffDialog from '../../components/InviteStaffDialog';
+import InviteMemberDialog from '../../components/InviteMemberDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
@@ -523,7 +523,7 @@ const AdminMembers = () => {
onSuccess={refetch}
/>
- {
const { hasPermission } = useAuth();
@@ -61,6 +63,9 @@ const AdminSubscriptions = () => {
const [exporting, setExporting] = useState(false);
const [expandedRows, setExpandedRows] = useState(new Set());
+ //create subsdcription dialog state
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
+
// Edit subscription dialog state
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [selectedSubscription, setSelectedSubscription] = useState(null);
@@ -313,671 +318,691 @@ Proceed with activation?`;
}
return (
-
- {/* Header */}
-
-
- Subscription Management
-
-
- View and manage all member subscriptions
-
-
+ <>
+
+ {/* Header */}
+
- {/* Stats Cards */}
-
-
-
-
-
- Total Subscriptions
-
-
- {stats.total || 0}
-
-
-
-
-
-
-
-
-
-
-
-
- Active Members
-
-
- {stats.active || 0}
-
-
-
-
-
-
-
-
-
-
-
-
- Total Revenue
-
-
- {formatPrice(stats.total_revenue || 0)}
-
-
-
-
-
-
-
-
-
-
-
-
- Total Donations
-
-
- {formatPrice(stats.total_donations || 0)}
-
-
-
-
-
-
-
-
-
- {/* Search & Filter Bar */}
-
-
- {/* Search */}
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
- />
-
-
-
- {/* Status Filter */}
-
+
+ Subscription Management
+
+
+ View and manage all member subscriptions
+
-
- {/* Plan Filter */}
-
-
-
-
-
-
-
- Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
-
-
- {/* Export Dropdown */}
- {hasPermission('subscriptions.export') && (
-
-
-
-
-
- handleExport('all')}
- className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
- >
-
- Export All Subscriptions
-
- handleExport('current')}
- className="cursor-pointer hover:bg-[var(--lavender-300)] rounded-lg p-3"
- >
-
- Export Current View
-
-
-
- )}
-
-
-
- {/* Subscriptions Table */}
-
- {/* Mobile Card View */}
-
- {filteredSubscriptions.length > 0 ? (
- filteredSubscriptions.map((sub) => (
-
-
- {/* Member Info */}
-
-
-
- {sub.user.first_name} {sub.user.last_name}
-
-
- {sub.user.email}
-
-
-
-
-
- {/* Plan & Period */}
-
-
-
Plan
-
{sub.plan.name}
-
{sub.plan.billing_cycle}
-
-
-
Period
-
- {new Date(sub.current_period_start).toLocaleDateString()} -
- {new Date(sub.current_period_end).toLocaleDateString()}
-
-
-
-
- {/* Pricing */}
-
-
-
Base Fee
-
- ${(sub.base_fee_cents / 100).toFixed(2)}
-
-
-
-
Donation
-
- ${(sub.donation_cents / 100).toFixed(2)}
-
-
-
-
Total
-
- ${(sub.total_cents / 100).toFixed(2)}
-
-
-
-
- {/* Actions */}
-
- {hasPermission('subscriptions.edit') && (
-
- )}
- {sub.status === 'active' && hasPermission('subscriptions.cancel') && (
-
- )}
-
-
-
- ))
- ) : (
-
- No subscriptions found
-
+ {hasPermission('users.create') && (
+
)}
- {/* Desktop Table View */}
-
-
-
-
- |
- Member
- |
-
- Plan
- |
-
- Status
- |
-
- Period
- |
-
- Base Fee
- |
-
- Donation
- |
-
- Total
- |
-
- Details
- |
-
- Actions
- |
-
-
-
- {filteredSubscriptions.length > 0 ? (
- filteredSubscriptions.map((sub) => {
- const isExpanded = expandedRows.has(sub.id);
- return (
-
-
- |
-
- {sub.user.first_name} {sub.user.last_name}
-
-
- {sub.user.email}
-
- |
-
-
- {sub.plan.name}
-
-
- {sub.plan.billing_cycle}
-
- |
-
-
+ {/* Stats Cards */}
+
+
+
+
+
+ Total Subscriptions
+
+
+ {stats.total || 0}
+
+
+
+
+
+
+
- |
-
-
- {formatDate(sub.start_date)}
- to {formatDate(sub.end_date)}
-
- |
-
- {formatPrice(sub.base_subscription_cents || 0)}
- |
-
- {formatPrice(sub.donation_cents || 0)}
- |
-
- {formatPrice(sub.amount_paid_cents || 0)}
- |
-
-
- |
-
-
- {hasPermission('subscriptions.edit') && (
-
- )}
- {sub.status === 'active' && hasPermission('subscriptions.cancel') && (
-
- )}
-
- |
-
- {/* Expandable Details Row */}
- {isExpanded && (
-
-
-
-
- Transaction Details
-
-
- {/* Payment Information */}
-
-
-
- Payment Information
-
-
- {sub.payment_completed_at && (
-
- Payment Date:
- {formatDateTime(sub.payment_completed_at)}
-
- )}
- {sub.payment_method && (
-
- Payment Method:
- {sub.payment_method}
-
- )}
- {sub.card_brand && sub.card_last4 && (
-
- Card:
- {sub.card_brand} ****{sub.card_last4}
-
- )}
-
-
+
+
+
+
+ Active Members
+
+
+ {stats.active || 0}
+
+
+
+
+
+
+
- {/* Stripe Transaction IDs */}
-
-
-
- Stripe Transaction IDs
-
-
- {sub.stripe_payment_intent_id && (
-
- Payment Intent:
-
-
- {sub.stripe_payment_intent_id.substring(0, 20)}...
-
-
-
-
- )}
- {sub.stripe_charge_id && (
-
- Charge ID:
-
-
- {sub.stripe_charge_id.substring(0, 20)}...
-
-
-
-
- )}
- {sub.stripe_subscription_id && (
-
- Subscription ID:
-
-
- {sub.stripe_subscription_id.substring(0, 20)}...
-
-
-
-
- )}
- {sub.stripe_invoice_id && (
-
- Invoice ID:
-
-
- {sub.stripe_invoice_id.substring(0, 20)}...
-
-
-
-
- )}
- {sub.stripe_customer_id && (
-
- Customer ID:
-
-
- {sub.stripe_customer_id.substring(0, 20)}...
-
-
-
-
- )}
- {sub.stripe_receipt_url && (
-
- Receipt:
-
-
- )}
-
-
-
-
- |
-
- )}
-
- );
- })
- ) : (
-
- |
- No subscriptions found
- |
-
- )}
-
-
+
+
+
+
+ Total Revenue
+
+
+ {formatPrice(stats.total_revenue || 0)}
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Donations
+
+
+ {formatPrice(stats.total_donations || 0)}
+
+
+
+
+
+
+
-
- {/* Edit Subscription Dialog */}
-