From a247ac52199bcb460e6c9268ac2786e53873849f Mon Sep 17 00:00:00 2001 From: kayela Date: Wed, 28 Jan 2026 15:03:46 -0600 Subject: [PATCH 1/3] 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 --- src/components/InviteMemberDialog.js | 281 +++++++++++++++++++++++++++ src/components/ui/select.jsx | 6 +- src/pages/admin/AdminMembers.js | 4 +- src/pages/admin/AdminValidations.js | 67 +++++-- src/styles/theme.css | 72 ++++--- tailwind.config.js | 4 + 6 files changed, 382 insertions(+), 52 deletions(-) create mode 100644 src/components/InviteMemberDialog.js 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 ( + + + + + + {invitationUrl ? 'Invitation Sent' : 'Invite Member'} + + + {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.'} + + + + {invitationUrl ? ( + // Show invitation URL after successful send +
+ +
+ + +
+
+ ) : ( + // Show invitation form +
+
+ {/* Email */} +
+ + handleChange('email', e.target.value)} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " + placeholder="member@example.com" + /> + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* First Name (Optional) */} +
+ + handleChange('first_name', e.target.value)} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " + placeholder="Jane" + /> +
+ + {/* Last Name (Optional) */} +
+ + handleChange('last_name', e.target.value)} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " + placeholder="Doe" + /> +
+ + {/* Phone (Optional) */} +
+ + handleChange('phone', e.target.value)} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple " + placeholder="(555) 123-4567" + /> +
+ + +
+ + + + + +
+ )} + + {invitationUrl && ( + + + + )} +
+
+ ); +}; + +export default InviteMemberDialog; 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 [sortBy, setSortBy] = useState('created_at'); const [sortOrder, setSortOrder] = useState('desc'); + // Resend email state + const [resendLoading, setResendLoading] = useState(null); + useEffect(() => { fetchPendingUsers(); }, []); @@ -238,6 +241,21 @@ const AdminValidations = () => { + // 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) => { if (sortBy === column) { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); @@ -279,7 +297,7 @@ const AdminValidations = () => {
Quick Overview
-
+
{ dataTestId="stat-pending-validation" /> - u.status === 'pre_validated').length} - icon={CheckCircle} - iconBgClass="text-brand-purple" - dataTestId="stat-pre-validated" - /> - u.status === 'payment_pending').length} @@ -349,13 +359,12 @@ const AdminValidations = () => { - + All Statuses - Awaiting Email - Pending Validation - Pre-Validated - Payment Pending - Rejected + Awaiting Email + Pending Validation + Payment Pending + Rejected
@@ -392,6 +401,13 @@ const AdminValidations = () => { > Registered {renderSortIcon('created_at')} + handleSort('email_verification_expires_at')} + > + {/* TODO: change ' ' */} + Validation Expiry {renderSortIcon('email_verification_expires_at')} + Referred By Actions @@ -408,6 +424,11 @@ const AdminValidations = () => { {new Date(user.created_at).toLocaleDateString()} + + {user.email_verification_expires_at + ? new Date(user.email_verification_expires_at).toLocaleString() + : '—'} + {user.referred_by_member_name || '-'} @@ -429,11 +450,21 @@ const AdminValidations = () => { onClick={() => handleBypassAndValidateRequest(user)} disabled={actionLoading === user.id} size="sm" - className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background" + className="bg-secondary text-[var(--purple-ink)] hover:bg-secondary/80" > {actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'} )} + {hasPermission('users.approve') && ( + + )} {hasPermission('users.approve') && ( )} @@ -455,7 +485,6 @@ const AdminValidations = () => { size="sm" className="btn-light-lavender" > - Activate Payment )} @@ -467,7 +496,6 @@ const AdminValidations = () => { variant="outline" className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10" > - Reject )} @@ -492,7 +520,6 @@ const AdminValidations = () => { variant="outline" className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10" > - Reject )} diff --git a/src/styles/theme.css b/src/styles/theme.css index 7096ed1..707b7eb 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -2,32 +2,6 @@ @layer base { :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 ========================= */ @@ -47,7 +21,7 @@ /* ========================== - Color Patch + Social Media Colors ========================== */ @@ -55,6 +29,50 @@ --blue-facebook: #1877f2; --blue-twitter: #1da1f2; --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-2: #422268; --purple-deep: #48286e; diff --git a/tailwind.config.js b/tailwind.config.js index 9ebfc77..61d825a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -49,6 +49,10 @@ module.exports = { DEFAULT: 'hsl(var(--success))', foreground: 'hsl(var(--success-foreground))' }, + warning: { + DEFAULT: 'hsl(var(--warning))', + foreground: 'hsl(var(--warning-foreground))' + }, border: 'hsl(var(--border))', input: 'hsl(var(--input))', ring: 'hsl(var(--ring))', From d638afcdb2d78a4c329f7bf41305d0b31b24a15c Mon Sep 17 00:00:00 2001 From: kayela Date: Wed, 28 Jan 2026 18:59:19 -0600 Subject: [PATCH 2/3] 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 --- src/components/CreateSubscriptionDialog.js | 576 +++++++++ src/components/ViewRegistrationDialog.js | 172 +++ src/pages/admin/AdminSubscriptions.js | 1309 ++++++++++---------- src/pages/admin/AdminValidations.js | 215 ++-- 4 files changed, 1535 insertions(+), 737 deletions(-) create mode 100644 src/components/CreateSubscriptionDialog.js create mode 100644 src/components/ViewRegistrationDialog.js 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 ( + + + + + + Create Subscription + + + Search for an existing member and create a subscription with manual payment processing. + + + +
+
+ {/* User Search Section */} + {!selectedUser ? ( +
+ +
+ + setSearchQuery(e.target.value)} + className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + /> + {searchLoading && ( + + )} +
+ + {/* Search Results */} + {searchResults.length > 0 && ( + +
+ {searchResults.map((user) => ( + + ))} +
+
+ )} + + {searchQuery && !searchLoading && searchResults.length === 0 && ( +

+ No members found matching "{searchQuery}" +

+ )} +
+ ) : ( + /* Selected User Card */ + +
+
+
+ +
+
+

+ {selectedUser.first_name} {selectedUser.last_name} +

+

+ {selectedUser.email} +

+
+
+ +
+
+ )} + + {/* Payment Form - Only show when user is selected */} + {selectedUser && ( + <> + {/* Plan Selection */} +
+ + + {selectedPlan && ( +

+ {selectedPlan.description || `${selectedPlan.billing_cycle} subscription`} +

+ )} +
+ + {/* Payment Amount */} +
+ + setFormData({ ...formData, amount: e.target.value })} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + required + /> + {selectedPlan && ( +

+ Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)} +

+ )} +
+ + {/* Amount Breakdown */} + {breakdown && breakdown.total >= breakdown.base && ( + +
+
+ Membership Fee: + {formatPrice(breakdown.base)} +
+ {breakdown.donation > 0 && ( +
+ + + Additional Donation: + + {formatPrice(breakdown.donation)} +
+ )} +
+ Total: + {formatPrice(breakdown.total)} +
+
+
+ )} + + {/* Payment Date */} +
+ +
+ + setFormData({ ...formData, payment_date: e.target.value })} + className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + required + /> +
+
+ + {/* Payment Method */} +
+ + +
+ + {/* Subscription Period */} +
+ + +
+ setUseCustomPeriod(e.target.checked)} + className="rounded border-[var(--neutral-800)]" + /> + +
+ + {useCustomPeriod ? ( +
+
+ + setFormData({ ...formData, custom_period_start: e.target.value })} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + required={useCustomPeriod} + /> +
+
+ + setFormData({ ...formData, custom_period_end: e.target.value })} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + required={useCustomPeriod} + /> +
+
+ ) : ( + selectedPlan && ( +
+ {selectedPlan.custom_cycle_enabled ? ( + <> +

+ Plan uses custom billing cycle: +
+ {(() => { + 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)`; + })()} +

+

+ Subscription will end on the upcoming cycle end date based on today's date. +

+ + ) : ( +

+ Will use plan's billing cycle: {selectedPlan.billing_cycle} +
+ 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 +

+ )} +
+ ) + )} +
+ + {/* Notes */} +
+ +