From 97aa7860a940c2552c2969a39337ec342caf0051 Mon Sep 17 00:00:00 2001 From: kayela Date: Tue, 27 Jan 2026 14:33:36 -0600 Subject: [PATCH 1/6] feat: integrate TransactionHistory component into Dashboard and update styles for better UI consistency --- src/components/TransactionHistory.js | 4 +- src/pages/Dashboard.js | 260 ++++++++----------------- src/pages/admin/AdminFinancials.js | 7 +- src/pages/admin/AdminNewsletters.js | 7 +- src/pages/members/NewsletterArchive.js | 6 +- 5 files changed, 92 insertions(+), 192 deletions(-) diff --git a/src/components/TransactionHistory.js b/src/components/TransactionHistory.js index 9843a53..e375a27 100644 --- a/src/components/TransactionHistory.js +++ b/src/components/TransactionHistory.js @@ -70,9 +70,9 @@ const TransactionHistory = ({
{isSubscription ? ( - + ) : ( - + )}
diff --git a/src/pages/Dashboard.js b/src/pages/Dashboard.js index ee8ad38..6b74a27 100644 --- a/src/pages/Dashboard.js +++ b/src/pages/Dashboard.js @@ -9,6 +9,8 @@ import Navbar from '../components/Navbar'; import MemberFooter from '../components/MemberFooter'; import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react'; import { toast } from 'sonner'; +import TransactionHistory from '../components/TransactionHistory'; + const Dashboard = () => { const { user, resendVerificationEmail, refreshUser } = useAuth(); @@ -17,12 +19,15 @@ const Dashboard = () => { const [resendLoading, setResendLoading] = useState(false); const [eventActivity, setEventActivity] = useState(null); const [activityLoading, setActivityLoading] = useState(true); + const [transactionsLoading, setTransactionsLoading] = useState(true); + const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] }); const [activeTransactionTab, setActiveTransactionTab] = useState('all'); const joinedDate = user?.member_since || user?.created_at; useEffect(() => { fetchUpcomingEvents(); fetchEventActivity(); + fetchTransactions(); }, []); const fetchUpcomingEvents = async () => { @@ -48,6 +53,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 () => { setResendLoading(true); try { @@ -72,6 +90,7 @@ const Dashboard = () => { } }; + const getStatusBadge = (status) => { const statusConfig = { pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, @@ -183,67 +202,57 @@ const Dashboard = () => { {/* Grid Layout */}
{/* Quick Stats */} - -

- Quick Info -

-
-
-

Email

-

{user?.email}

-
-
-

Role

-

{user?.role}

-
-
-

Member Since

-

- {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'} -

-
- {user?.subscription_start_date && user?.subscription_end_date && ( - <> -
-

Membership Period

-

- {new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()} -

-
-
-

Days Remaining

-

- {Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days -

-
- - )} -
-
- -

- Membership Info -

-
+
- {user?.subscription_start_date && user?.subscription_end_date && ( - <> -
-

Membership Period

-

- {new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()} -

-
-
-

Days Remaining

-

- {Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days -

-
- - )} -
- + +

+ Quick Info +

+
+
+

Email

+

{user?.email}

+
+
+

Role

+

{user?.role}

+
+
+

Member Since

+

+ {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'} +

+
+ +
+
+ +

+ Membership Info +

+
+ {!user.subscription_end_date && !user.subscription_end_date && ( +
No subscriptions yet
+ )} + {user?.subscription_start_date && user?.subscription_end_date && ( + <> +
+

Membership Period

+

+ {new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()} +

+
+
+

Days Remaining

+

+ {Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days +

+
+ + )} +
+
+
{/* Upcoming Events */} @@ -337,127 +346,16 @@ const Dashboard = () => { )} {/* Transaction History Section */} - - {/* Header */} -
-
- -
-

- Transaction History -

-
- - {/* Stats Row */} -
-
-
- - - Total Subscriptions - -
-

- $30.00 -

-

- 1 payment(s) -

-
-
-
- - - Total Donations - -
-

- $0.00 -

-

- 0 donation(s) -

-
-
- - {/* Filter Tabs */} -
- - - -
- - {/* Transaction List */} -
- {(activeTransactionTab === 'all' || activeTransactionTab === 'subscriptions') && ( -
-
-
-
-
- - Annual Membership - - - active - -
-
- - Dec 16, 2025 - - Custom -
- - Manual Payment - -
-
- - $30.00 - -
- )} - {activeTransactionTab === 'donations' && ( -
- -

- No donations yet -

-
- )} -
-
+
+ +
diff --git a/src/pages/admin/AdminFinancials.js b/src/pages/admin/AdminFinancials.js index d29bc67..7838efd 100644 --- a/src/pages/admin/AdminFinancials.js +++ b/src/pages/admin/AdminFinancials.js @@ -191,10 +191,9 @@ const AdminFinancials = () => {
{reports.map(report => ( -
-
- -
{report.year}
+
+
+

diff --git a/src/pages/admin/AdminNewsletters.js b/src/pages/admin/AdminNewsletters.js index 479a6fb..bb4a565 100644 --- a/src/pages/admin/AdminNewsletters.js +++ b/src/pages/admin/AdminNewsletters.js @@ -223,10 +223,13 @@ const AdminNewsletters = () => { {year}

-
+
{groupedNewsletters[year].map(newsletter => ( -
+
+
+ +

{newsletter.title} diff --git a/src/pages/members/NewsletterArchive.js b/src/pages/members/NewsletterArchive.js index 9febb0a..48513bc 100644 --- a/src/pages/members/NewsletterArchive.js +++ b/src/pages/members/NewsletterArchive.js @@ -167,9 +167,9 @@ export default function NewsletterArchive() { {groupedNewsletters[year].map(newsletter => (
-
- -
+
+ +

{newsletter.title} -- 2.39.5 From 0c3d4a4edd07e96fe4e4d94e4d5662e57b4a75a8 Mon Sep 17 00:00:00 2001 From: kayela Date: Tue, 27 Jan 2026 15:11:25 -0600 Subject: [PATCH 2/6] Updated stat cards to be consistent with rest of codebase --- src/pages/admin/AdminValidations.js | 98 +++++++++++++++++------------ 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/src/pages/admin/AdminValidations.js b/src/pages/admin/AdminValidations.js index c26a5fd..96b62c9 100644 --- a/src/pages/admin/AdminValidations.js +++ b/src/pages/admin/AdminValidations.js @@ -29,11 +29,12 @@ import { PaginationEllipsis, } from '../../components/ui/pagination'; import { toast } from 'sonner'; -import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X } from 'lucide-react'; +import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X, XCircle } from 'lucide-react'; import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog'; import RejectionDialog from '../../components/RejectionDialog'; import StatusBadge from '@/components/StatusBadge'; +import { StatCard } from '@/components/StatCard'; const AdminValidations = () => { const { hasPermission } = useAuth(); @@ -235,7 +236,7 @@ const AdminValidations = () => { } }; - + const handleSort = (column) => { if (sortBy === column) { @@ -272,45 +273,62 @@ const AdminValidations = () => {

+ {/* Stats Card */} - -
-
-

Total Pending

-

- {pendingUsers.length} -

-
-
-

Awaiting Email

-

- {pendingUsers.filter(u => u.status === 'pending_email').length} -

-
-
-

Pending Validation

-

- {pendingUsers.filter(u => u.status === 'pending_validation').length} -

-
-
-

Pre-Validated

-

- {pendingUsers.filter(u => u.status === 'pre_validated').length} -

-
-
-

Payment Pending

-

- {pendingUsers.filter(u => u.status === 'payment_pending').length} -

-
-
-

Rejected

-

- {pendingUsers.filter(u => u.status === 'rejected').length} -

-
+ +
+ Quick Overview +
+
+ + + u.status === 'pending_email').length} + icon={CheckCircle} + iconBgClass="text-brand-purple" + dataTestId="stat-total-users" + /> + + u.status === 'pending_validation').length} + icon={CheckCircle} + iconBgClass="text-brand-purple" + dataTestId="stat-pending-validation" + /> + + u.status === 'pre_validated').length} + icon={CheckCircle} + iconBgClass="text-brand-purple" + dataTestId="stat-pre-validated" + /> + + u.status === 'payment_pending').length} + icon={CheckCircle} + iconBgClass="text-brand-purple" + dataTestId="stat-payment-pending" + /> + + u.status === 'rejected').length} + icon={XCircle} + iconBgClass="text-red-600" + dataTestId="stat-rejected" + /> + +
-- 2.39.5 From 0d7e3a12864ea1cd17cf1a059f1f51f95ee7c568 Mon Sep 17 00:00:00 2001 From: kayela Date: Tue, 27 Jan 2026 15:19:19 -0600 Subject: [PATCH 3/6] tweaked statcard for better styling when digits are greater than 2 --- src/components/StatCard.jsx | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/components/StatCard.jsx b/src/components/StatCard.jsx index 5afc9f2..377cc0b 100644 --- a/src/components/StatCard.jsx +++ b/src/components/StatCard.jsx @@ -12,27 +12,10 @@ export const StatCard = ({ const digitCount = 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 = () => { switch (true) { - case digitCount <= 3: + case digitCount <= 2: // 3.75rem for 3 or fewer digits return "3.75rem"; case digitCount <= 6: @@ -68,8 +51,8 @@ const getValueFontSize = () => {

-
- +
+

Date: Tue, 27 Jan 2026 16:30:26 -0600 Subject: [PATCH 4/6] 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 --- src/components/MemberBadge.js | 8 +- src/components/MemberCard.js | 15 +- src/config/MemberTiers.js | 77 ++++-- src/config/memberTierIcons.js | 32 ++- src/hooks/use-member-tiers.js | 106 ++++++++ src/pages/Dashboard.js | 29 +- src/pages/admin/AdminMemberTiers.js | 376 ++++++++++++++++++++++++-- src/pages/members/MembersDirectory.js | 38 ++- src/utils/member-tiers.js | 47 +++- 9 files changed, 646 insertions(+), 82 deletions(-) create mode 100644 src/hooks/use-member-tiers.js diff --git a/src/components/MemberBadge.js b/src/components/MemberBadge.js index a7e1387..7a66590 100644 --- a/src/components/MemberBadge.js +++ b/src/components/MemberBadge.js @@ -2,15 +2,15 @@ import React from 'react'; import { Badge } from './ui/badge'; import { getTierForMember } from '../utils/member-tiers'; -import { MEMBER_TIER_ICONS } from '../config/memberTierIcons'; +import { getTierIcon } from '../config/memberTierIcons'; const MemberBadge = ({ memberSince, tiers }) => { const tier = getTierForMember(memberSince, tiers); - const Icon = MEMBER_TIER_ICONS[tier.icon] || MEMBER_TIER_ICONS.FaUser; + const Icon = getTierIcon(tier.iconKey); return ( - - + + {tier.label} ); diff --git a/src/components/MemberCard.js b/src/components/MemberCard.js index 40bb13f..c61dcce 100644 --- a/src/components/MemberCard.js +++ b/src/components/MemberCard.js @@ -2,6 +2,7 @@ import React from 'react' import { Card } from './ui/card'; import { Button } from './ui/button'; import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react'; +import MemberBadge from './MemberBadge'; // Helper function to get initials const getInitials = (firstName, lastName) => { @@ -17,13 +18,13 @@ const getSocialMediaLink = (url) => { return url; }; -const MemberCard = ({ member, onViewProfile }) => { - const joinedDate = member.created_at; +const MemberCard = ({ member, onViewProfile, tiers }) => { + const memberSince = member.member_since || member.created_at; return ( - {/* Profile Photo */} -

- member since badge + {/* Member Tier Badge */} +
+
{member.profile_photo_url ? ( @@ -64,11 +65,11 @@ const MemberCard = ({ member, onViewProfile }) => { )} {/* Member Since */} - {joinedDate && ( + {memberSince && (
- Member since {new Date(joinedDate).toLocaleDateString('en-US', { + Member since {new Date(memberSince).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} diff --git a/src/config/MemberTiers.js b/src/config/MemberTiers.js index aa02d8a..f1c4aca 100644 --- a/src/config/MemberTiers.js +++ b/src/config/MemberTiers.js @@ -1,27 +1,70 @@ // 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 = [ { - id: 'new', + id: 'new_member', label: 'New Member', - minDays: 0, - maxDays: 364, // < 1 year - icon: 'FaSeedling', - badgeClass: 'bg-[var(--lavender-300)] text-[var(--purple-ink)] hover:text-white', + minYears: 0, + maxYears: 0.999, + iconKey: 'sparkle', + badgeClass: 'bg-blue-100 text-blue-800 border-blue-200', }, { - id: 'silver', - label: 'Silver Member', - minDays: 365, - maxDays: 729, - icon: 'FaMedal', - badgeClass: 'bg-slate-200 text-slate-900 hover:text-white', + id: 'member_1_year', + label: '1 Year Member', + minYears: 1, + maxYears: 2.999, + iconKey: 'star', + badgeClass: 'bg-green-100 text-green-800 border-green-200', }, { - id: 'gold', - label: 'Gold Member', - minDays: 730, - maxDays: null, // open-ended - icon: 'FaCrown', - badgeClass: 'bg-amber-200 text-amber-900 hover:text-white', + id: 'member_3_year', + label: '3+ Year Member', + minYears: 3, + maxYears: 4.999, + iconKey: 'award', + 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' }, ]; \ No newline at end of file diff --git a/src/config/memberTierIcons.js b/src/config/memberTierIcons.js index 40f70d8..957584b 100644 --- a/src/config/memberTierIcons.js +++ b/src/config/memberTierIcons.js @@ -1,9 +1,29 @@ // 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 = { - FaSeedling, - FaMedal, - FaCrown, - FaUser, -}; \ No newline at end of file + // Primary tier icons + sparkle: Sparkles, + sparkles: Sparkles, + 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; +}; diff --git a/src/hooks/use-member-tiers.js b/src/hooks/use-member-tiers.js new file mode 100644 index 0000000..edc32b7 --- /dev/null +++ b/src/hooks/use-member-tiers.js @@ -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} 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} 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; diff --git a/src/pages/Dashboard.js b/src/pages/Dashboard.js index 6b74a27..7f740d0 100644 --- a/src/pages/Dashboard.js +++ b/src/pages/Dashboard.js @@ -10,7 +10,8 @@ import MemberFooter from '../components/MemberFooter'; import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react'; import { toast } from 'sonner'; import TransactionHistory from '../components/TransactionHistory'; - +import MemberBadge from '@/components/MemberBadge'; +import useMemberTiers from '../hooks/use-member-tiers' const Dashboard = () => { const { user, resendVerificationEmail, refreshUser } = useAuth(); @@ -23,6 +24,7 @@ const Dashboard = () => { const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] }); const [activeTransactionTab, setActiveTransactionTab] = useState('all'); const joinedDate = user?.member_since || user?.created_at; + const { tiers, loading: tiersLoading } = useMemberTiers(); useEffect(() => { fetchUpcomingEvents(); @@ -131,6 +133,8 @@ const Dashboard = () => { return messages[status] || ''; }; + + return (
@@ -209,20 +213,31 @@ const Dashboard = () => { Quick Info

+ {/* member date and badge */} +
+
+

Member Since

+

+ {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'} +

+
+ {!tiersLoading && ( +
+ +
+ )} +
+ {/* email */}

Email

{user?.email}

+ {/* role */}

Role

{user?.role}

-
-

Member Since

-

- {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'} -

-
+
diff --git a/src/pages/admin/AdminMemberTiers.js b/src/pages/admin/AdminMemberTiers.js index 3c2d764..997fbc4 100644 --- a/src/pages/admin/AdminMemberTiers.js +++ b/src/pages/admin/AdminMemberTiers.js @@ -1,43 +1,361 @@ -import React from 'react'; -import { Card } from '../../components/ui/card'; +import React, { useState, useEffect, useCallback } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; 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 { 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 ( +
+
+
+ ); + } + return (
-

- Configure tier names, time ranges, and badges used in the members directory. -

+ {/* Header and Actions */} +
+

+ Configure tier names, time ranges, and badges displayed in the members directory. +

+
+ {hasChanges && ( + + )} + {isSuperAdmin && ( + + )} + +
+
- -
- {DEFAULT_MEMBER_TIERS.map((tier) => { - const rangeLabel = tier.maxDays == null - ? `${tier.minDays}+ days` - : `${tier.minDays}–${tier.maxDays} days`; + {/* Tier Cards */} +
+ {editedTiers.map((tier, index) => { + const IconComponent = getTierIcon(tier.iconKey); - return ( -
-
-
- {tier.label} + return ( + + +
+ {/* Drag Handle & Remove */} +
+ +
-
- {rangeLabel} + + {/* Tier Configuration */} +
+ {/* Label */} +
+ + handleTierChange(index, 'label', e.target.value)} + placeholder="Tier Name" + /> +
+ + {/* Min Years */} +
+ + handleTierChange(index, 'minYears', parseFloat(e.target.value) || 0)} + /> +
+ + {/* Max Years */} +
+ + handleTierChange(index, 'maxYears', parseFloat(e.target.value) || 0)} + /> +
+ + {/* Icon */} +
+ + +
+ + {/* Badge Color */} +
+ + +
+ + {/* Preview */} +
+ +
+ + + {tier.label || 'Tier Name'} + +
+
- - {tier.icon} - -
- ); - })} -
+ + + ); + })} +
+ + {/* Add Tier Button */} + + + {/* Info Card */} + + + + + How Member Tiers Work + + + +

+ 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. +

+
    +
  • Tiers are matched based on membership duration in years
  • +
  • Each tier should have non-overlapping year ranges
  • +
  • The last tier typically uses a high max value (e.g., 999) to catch all long-term members
  • +
+
+ + {/* Reset Confirmation Dialog */} + + + + + + Reset Tiers to Defaults? + + + This will delete all custom tier configurations and restore the default member tiers. + This action cannot be undone. + + + + Cancel + + Reset to Defaults + + + +
); }; diff --git a/src/pages/members/MembersDirectory.js b/src/pages/members/MembersDirectory.js index 3d27f33..0e57272 100644 --- a/src/pages/members/MembersDirectory.js +++ b/src/pages/members/MembersDirectory.js @@ -15,9 +15,10 @@ import { } from '../../components/ui/dialog'; import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react'; import { useToast } from '../../hooks/use-toast'; -import StatusBadge from '@/components/StatusBadge'; import MemberCard from '../../components/MemberCard'; +import MemberBadge from '../../components/MemberBadge'; import useMembers from '../../hooks/use-members'; +import useMemberTiers from '../../hooks/use-member-tiers'; const MembersDirectory = () => { const [selectedMember, setSelectedMember] = useState(null); @@ -25,7 +26,27 @@ const MembersDirectory = () => { const { toast } = useToast(); const [currentPage, setCurrentPage] = useState(1); const pageSize = 12; + const { tiers } = useMemberTiers(); 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( (member) => [ `${member.first_name} ${member.last_name}`, @@ -47,12 +68,14 @@ const MembersDirectory = () => { loading, searchQuery, setSearchQuery, + filterValue, } = useMembers({ endpoint: '/members/directory', - initialFilter: 'active', + initialFilter: 'all', filterKey: 'status', allowedRoles, searchAccessor, + transform: normalizeMembers, fetchErrorMessage: 'Failed to load members directory. Please try again.', onFetchError: handleFetchError }); @@ -67,7 +90,12 @@ const MembersDirectory = () => { 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]); @@ -158,7 +186,7 @@ const MembersDirectory = () => { ) : filteredMembers.length > 0 ? (
{paginatedMembers.map((member) => ( - + ))}
) : ( @@ -210,7 +238,7 @@ const MembersDirectory = () => { {selectedMember.first_name} {selectedMember.last_name} - + {selectedMember.directory_partner_name && ( diff --git a/src/utils/member-tiers.js b/src/utils/member-tiers.js index a4c86ac..5a4c78e 100644 --- a/src/utils/member-tiers.js +++ b/src/utils/member-tiers.js @@ -1,23 +1,56 @@ // src/utils/member-tiers.js 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; const since = new Date(memberSince); 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) => { - const days = getTenureDays(memberSince); - if (days == null) return tiers[0]; + const years = getTenureYears(memberSince); + if (years == null) return tiers[0]; const match = tiers.find( (tier) => - days >= tier.minDays && - (tier.maxDays == null || days <= tier.maxDays) + years >= tier.minYears && + (tier.maxYears == null || years <= tier.maxYears) ); 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`; }; \ No newline at end of file -- 2.39.5 From 378b909398660f5b3099e33a038d7e9ba37ee5cc Mon Sep 17 00:00:00 2001 From: kayela Date: Tue, 27 Jan 2026 16:38:21 -0600 Subject: [PATCH 5/6] removed transaction history from Profile.js --- src/pages/Profile.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/pages/Profile.js b/src/pages/Profile.js index d270dfa..236fe4e 100644 --- a/src/pages/Profile.js +++ b/src/pages/Profile.js @@ -721,17 +721,6 @@ const Profile = () => { onOpenChange={setPasswordDialogOpen} /> - {/* Transaction History Section */} -
- -
-- 2.39.5 From 01722edad944125f51acf2312e23d86fea5ee088 Mon Sep 17 00:00:00 2001 From: kayela Date: Tue, 27 Jan 2026 17:30:50 -0600 Subject: [PATCH 6/6] updated badge glitch --- src/components/ui/badge.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx index 6907614..d0af7c0 100644 --- a/src/components/ui/badge.jsx +++ b/src/components/ui/badge.jsx @@ -9,7 +9,7 @@ const badgeVariants = cva( variants: { variant: { default: - "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + "border-transparent bg-primary text-primary-foreground shadow ", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: -- 2.39.5