From a247ac52199bcb460e6c9268ac2786e53873849f Mon Sep 17 00:00:00 2001 From: kayela Date: Wed, 28 Jan 2026 15:03:46 -0600 Subject: [PATCH] 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))',