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