Compare commits
7 Commits
467f34b42a
...
a1c68eedc2
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c68eedc2 | |||
|
|
01722edad9 | ||
|
|
378b909398 | ||
|
|
4ad1997bd5 | ||
|
|
0d7e3a1286 | ||
|
|
0c3d4a4edd | ||
|
|
97aa7860a9 |
@@ -2,15 +2,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { getTierForMember } from '../utils/member-tiers';
|
import { getTierForMember } from '../utils/member-tiers';
|
||||||
import { MEMBER_TIER_ICONS } from '../config/memberTierIcons';
|
import { getTierIcon } from '../config/memberTierIcons';
|
||||||
|
|
||||||
const MemberBadge = ({ memberSince, tiers }) => {
|
const MemberBadge = ({ memberSince, tiers }) => {
|
||||||
const tier = getTierForMember(memberSince, tiers);
|
const tier = getTierForMember(memberSince, tiers);
|
||||||
const Icon = MEMBER_TIER_ICONS[tier.icon] || MEMBER_TIER_ICONS.FaUser;
|
const Icon = getTierIcon(tier.iconKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge className={`px-3 py-1 rounded-md text-sm flex items-center gap-2 ${tier.badgeClass}`}>
|
<Badge className={`px-3 py-2 rounded-md text-sm flex items-center gap-2 border hover:text-white ${tier.badgeClass}`}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="size-6" />
|
||||||
{tier.label}
|
{tier.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
|
import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
|
||||||
|
import MemberBadge from './MemberBadge';
|
||||||
|
|
||||||
// Helper function to get initials
|
// Helper function to get initials
|
||||||
const getInitials = (firstName, lastName) => {
|
const getInitials = (firstName, lastName) => {
|
||||||
@@ -17,13 +18,13 @@ const getSocialMediaLink = (url) => {
|
|||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MemberCard = ({ member, onViewProfile }) => {
|
const MemberCard = ({ member, onViewProfile, tiers }) => {
|
||||||
const joinedDate = member.created_at;
|
const memberSince = member.member_since || member.created_at;
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
|
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
|
||||||
{/* Profile Photo */}
|
{/* Member Tier Badge */}
|
||||||
<div className='flex justify-end items-center'>
|
<div className='flex justify-end items-center mb-2'>
|
||||||
member since badge
|
<MemberBadge memberSince={memberSince} tiers={tiers} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{member.profile_photo_url ? (
|
{member.profile_photo_url ? (
|
||||||
@@ -64,11 +65,11 @@ const MemberCard = ({ member, onViewProfile }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Member Since */}
|
{/* Member Since */}
|
||||||
{joinedDate && (
|
{memberSince && (
|
||||||
<div className="flex items-center justify-center gap-2 mb-4">
|
<div className="flex items-center justify-center gap-2 mb-4">
|
||||||
<Calendar className="h-4 w-4 text-brand-purple " />
|
<Calendar className="h-4 w-4 text-brand-purple " />
|
||||||
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
Member since {new Date(joinedDate).toLocaleDateString('en-US', {
|
Member since {new Date(memberSince).toLocaleDateString('en-US', {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,27 +12,10 @@ export const StatCard = ({
|
|||||||
|
|
||||||
const digitCount =
|
const digitCount =
|
||||||
valueString.replace(/\D/g, "").length || valueString.length;
|
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 = () => {
|
const getValueFontSize = () => {
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case digitCount <= 3:
|
case digitCount <= 2:
|
||||||
// 3.75rem for 3 or fewer digits
|
// 3.75rem for 3 or fewer digits
|
||||||
return "3.75rem";
|
return "3.75rem";
|
||||||
case digitCount <= 6:
|
case digitCount <= 6:
|
||||||
@@ -68,8 +51,8 @@ const getValueFontSize = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${iconBgClass} p-3 rounded-lg `}>
|
<div className={`${iconBgClass} px-3 py-2 rounded-lg `}>
|
||||||
<Icon className="size-8" />
|
<Icon className="size-[valueFontSize]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ const TransactionHistory = ({
|
|||||||
<div className="flex items-start gap-3 mb-2 sm:mb-0">
|
<div className="flex items-start gap-3 mb-2 sm:mb-0">
|
||||||
<div className={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
|
<div className={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
|
||||||
{isSubscription ? (
|
{isSubscription ? (
|
||||||
<CreditCard className="h-5 w-5 text-[var(--purple-lavender)]" />
|
<CreditCard className="h-5 w-5 text-white" />
|
||||||
) : (
|
) : (
|
||||||
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
<Heart className="h-5 w-5 text-white" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const badgeVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
"border-transparent bg-primary text-primary-foreground shadow ",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
|
|||||||
@@ -1,27 +1,70 @@
|
|||||||
// src/config/memberTiers.js
|
// 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 = [
|
export const DEFAULT_MEMBER_TIERS = [
|
||||||
{
|
{
|
||||||
id: 'new',
|
id: 'new_member',
|
||||||
label: 'New Member',
|
label: 'New Member',
|
||||||
minDays: 0,
|
minYears: 0,
|
||||||
maxDays: 364, // < 1 year
|
maxYears: 0.999,
|
||||||
icon: 'FaSeedling',
|
iconKey: 'sparkle',
|
||||||
badgeClass: 'bg-[var(--lavender-300)] text-[var(--purple-ink)] hover:text-white',
|
badgeClass: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'silver',
|
id: 'member_1_year',
|
||||||
label: 'Silver Member',
|
label: '1 Year Member',
|
||||||
minDays: 365,
|
minYears: 1,
|
||||||
maxDays: 729,
|
maxYears: 2.999,
|
||||||
icon: 'FaMedal',
|
iconKey: 'star',
|
||||||
badgeClass: 'bg-slate-200 text-slate-900 hover:text-white',
|
badgeClass: 'bg-green-100 text-green-800 border-green-200',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gold',
|
id: 'member_3_year',
|
||||||
label: 'Gold Member',
|
label: '3+ Year Member',
|
||||||
minDays: 730,
|
minYears: 3,
|
||||||
maxDays: null, // open-ended
|
maxYears: 4.999,
|
||||||
icon: 'FaCrown',
|
iconKey: 'award',
|
||||||
badgeClass: 'bg-amber-200 text-amber-900 hover:text-white',
|
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' },
|
||||||
|
];
|
||||||
@@ -1,9 +1,29 @@
|
|||||||
// src/config/memberTierIcons.js
|
// 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 = {
|
export const MEMBER_TIER_ICONS = {
|
||||||
FaSeedling,
|
// Primary tier icons
|
||||||
FaMedal,
|
sparkle: Sparkles,
|
||||||
FaCrown,
|
sparkles: Sparkles,
|
||||||
FaUser,
|
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;
|
||||||
};
|
};
|
||||||
106
src/hooks/use-member-tiers.js
Normal file
106
src/hooks/use-member-tiers.js
Normal file
@@ -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<boolean>} 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<boolean>} 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;
|
||||||
@@ -9,6 +9,9 @@ import Navbar from '../components/Navbar';
|
|||||||
import MemberFooter from '../components/MemberFooter';
|
import MemberFooter from '../components/MemberFooter';
|
||||||
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
|
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import TransactionHistory from '../components/TransactionHistory';
|
||||||
|
import MemberBadge from '@/components/MemberBadge';
|
||||||
|
import useMemberTiers from '../hooks/use-member-tiers'
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { user, resendVerificationEmail, refreshUser } = useAuth();
|
const { user, resendVerificationEmail, refreshUser } = useAuth();
|
||||||
@@ -17,12 +20,16 @@ const Dashboard = () => {
|
|||||||
const [resendLoading, setResendLoading] = useState(false);
|
const [resendLoading, setResendLoading] = useState(false);
|
||||||
const [eventActivity, setEventActivity] = useState(null);
|
const [eventActivity, setEventActivity] = useState(null);
|
||||||
const [activityLoading, setActivityLoading] = useState(true);
|
const [activityLoading, setActivityLoading] = useState(true);
|
||||||
|
const [transactionsLoading, setTransactionsLoading] = useState(true);
|
||||||
|
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
|
||||||
const [activeTransactionTab, setActiveTransactionTab] = useState('all');
|
const [activeTransactionTab, setActiveTransactionTab] = useState('all');
|
||||||
const joinedDate = user?.member_since || user?.created_at;
|
const joinedDate = user?.member_since || user?.created_at;
|
||||||
|
const { tiers, loading: tiersLoading } = useMemberTiers();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUpcomingEvents();
|
fetchUpcomingEvents();
|
||||||
fetchEventActivity();
|
fetchEventActivity();
|
||||||
|
fetchTransactions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchUpcomingEvents = async () => {
|
const fetchUpcomingEvents = async () => {
|
||||||
@@ -48,6 +55,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 () => {
|
const handleResendVerification = async () => {
|
||||||
setResendLoading(true);
|
setResendLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -72,6 +92,7 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const getStatusBadge = (status) => {
|
const getStatusBadge = (status) => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
||||||
@@ -112,6 +133,8 @@ const Dashboard = () => {
|
|||||||
return messages[status] || '';
|
return messages[status] || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@@ -183,67 +206,68 @@ const Dashboard = () => {
|
|||||||
{/* Grid Layout */}
|
{/* Grid Layout */}
|
||||||
<div className="grid lg:grid-cols-2 gap-8">
|
<div className="grid lg:grid-cols-2 gap-8">
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
|
<div className='space-y-8'>
|
||||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Quick Info
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
|
||||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
|
|
||||||
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
|
|
||||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{user?.subscription_start_date && user?.subscription_end_date && (
|
|
||||||
<>
|
|
||||||
<div className="pt-4 border-t border-[var(--neutral-800)]">
|
|
||||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
|
|
||||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
|
|
||||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
|
|
||||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Membership Info
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
|
|
||||||
{user?.subscription_start_date && user?.subscription_end_date && (
|
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
|
||||||
<>
|
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
<div className="pt-4 border-t border-[var(--neutral-800)]">
|
Quick Info
|
||||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
|
</h3>
|
||||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<div className="space-y-4">
|
||||||
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
|
{/* member date and badge */}
|
||||||
</p>
|
<div className='flex justify-between'>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
|
||||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
|
{joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
{!tiersLoading && (
|
||||||
)}
|
<div className='lg:mr-10'>
|
||||||
</div>
|
<MemberBadge memberSince={joinedDate} tiers={tiers} />
|
||||||
</Card>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* email */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||||||
|
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
{/* role */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
|
||||||
|
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
|
||||||
|
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Membership Info
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!user.subscription_end_date && !user.subscription_end_date && (
|
||||||
|
<div>No subscriptions yet</div>
|
||||||
|
)}
|
||||||
|
{user?.subscription_start_date && user?.subscription_end_date && (
|
||||||
|
<>
|
||||||
|
<div className="pt-4 border-t border-[var(--neutral-800)]">
|
||||||
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
|
||||||
|
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
|
||||||
|
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Events */}
|
{/* Upcoming Events */}
|
||||||
<Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
|
<Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
|
||||||
@@ -337,127 +361,16 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transaction History Section */}
|
{/* Transaction History Section */}
|
||||||
<Card className="mt-12 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
<div className="mt-8">
|
||||||
{/* Header */}
|
<TransactionHistory
|
||||||
<div className="flex items-center gap-3 mb-6">
|
subscriptions={transactions.subscriptions}
|
||||||
<div className="p-2 bg-[var(--green-light)] rounded-lg">
|
donations={transactions.donations}
|
||||||
<Receipt className="h-5 w-5 text-white" />
|
totalSubscriptionCents={transactions.total_subscription_amount_cents}
|
||||||
</div>
|
totalDonationCents={transactions.total_donation_amount_cents}
|
||||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
loading={transactionsLoading}
|
||||||
Transaction History
|
isAdmin={false}
|
||||||
</h2>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Row */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
|
||||||
<div className="p-4 bg-[var(--lavender-300)]/30 rounded-xl border border-[var(--neutral-800)]">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<CreditCard className="h-4 w-4 text-[var(--green-light)]" />
|
|
||||||
<span className="text-sm text-[var(--green-light)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Total Subscriptions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
$30.00
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
1 payment(s)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-[var(--lavender-300)]/30 rounded-xl border border-[var(--neutral-800)]">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Heart className="h-4 w-4 text-[var(--coral)]" />
|
|
||||||
<span className="text-sm text-[var(--coral)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Total Donations
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
$0.00
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
0 donation(s)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
|
||||||
<div className="flex gap-2 mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTransactionTab('all')}
|
|
||||||
className={`px-6 py-2 rounded-full font-medium transition-all ${
|
|
||||||
activeTransactionTab === 'all'
|
|
||||||
? 'bg-[var(--purple-lavender)] text-white'
|
|
||||||
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
|
|
||||||
}`}
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
All (1)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTransactionTab('subscriptions')}
|
|
||||||
className={`px-6 py-2 rounded-full font-medium transition-all ${
|
|
||||||
activeTransactionTab === 'subscriptions'
|
|
||||||
? 'bg-[var(--purple-lavender)] text-white'
|
|
||||||
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
|
|
||||||
}`}
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
Subscriptions (1)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTransactionTab('donations')}
|
|
||||||
className={`px-6 py-2 rounded-full font-medium transition-all ${
|
|
||||||
activeTransactionTab === 'donations'
|
|
||||||
? 'bg-[var(--purple-lavender)] text-white'
|
|
||||||
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
|
|
||||||
}`}
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
Donations (0)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transaction List */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{(activeTransactionTab === 'all' || activeTransactionTab === 'subscriptions') && (
|
|
||||||
<div className="flex items-center justify-between p-4 border border-[var(--neutral-800)] rounded-xl">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 bg-[var(--purple-lavender)] rounded-lg"></div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Annual Membership
|
|
||||||
</span>
|
|
||||||
<Badge className="bg-[var(--green-light)] text-white text-xs px-2 py-0.5 rounded">
|
|
||||||
active
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
|
||||||
<span>Dec 16, 2025</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Custom</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--coral)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Manual Payment
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
$30.00
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTransactionTab === 'donations' && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Heart className="h-12 w-12 text-[var(--neutral-800)] mx-auto mb-3" />
|
|
||||||
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
No donations yet
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<MemberFooter />
|
<MemberFooter />
|
||||||
|
|||||||
@@ -721,17 +721,6 @@ const Profile = () => {
|
|||||||
onOpenChange={setPasswordDialogOpen}
|
onOpenChange={setPasswordDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Transaction History Section */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<TransactionHistory
|
|
||||||
subscriptions={transactions.subscriptions}
|
|
||||||
donations={transactions.donations}
|
|
||||||
totalSubscriptionCents={transactions.total_subscription_amount_cents}
|
|
||||||
totalDonationCents={transactions.total_donation_amount_cents}
|
|
||||||
loading={transactionsLoading}
|
|
||||||
isAdmin={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<MemberFooter />
|
<MemberFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,10 +191,9 @@ const AdminFinancials = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{reports.map(report => (
|
{reports.map(report => (
|
||||||
<Card key={report.id} className="p-6">
|
<Card key={report.id} className="p-6">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-light-lavender p-4 rounded-xl min-w-[100px] text-center">
|
<div className="bg-light-lavender p-3 rounded-xl self-center">
|
||||||
<DollarSign className="h-6 w-6 mx-auto mb-1" />
|
<DollarSign className="size-8 " />
|
||||||
<div className="text-2xl font-bold">{report.year}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
||||||
|
|||||||
@@ -1,43 +1,361 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
||||||
import { Badge } from '../../components/ui/badge';
|
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 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 (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<p className="text-muted-foreground">
|
{/* Header and Actions */}
|
||||||
Configure tier names, time ranges, and badges used in the members directory.
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
</p>
|
<p className="text-muted-foreground">
|
||||||
|
Configure tier names, time ranges, and badges displayed in the members directory.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasChanges && (
|
||||||
|
<Button variant="outline" onClick={handleDiscardChanges}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowResetDialog(true)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSave} disabled={saving || !hasChanges}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
{/* Tier Cards */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{DEFAULT_MEMBER_TIERS.map((tier) => {
|
{editedTiers.map((tier, index) => {
|
||||||
const rangeLabel = tier.maxDays == null
|
const IconComponent = getTierIcon(tier.iconKey);
|
||||||
? `${tier.minDays}+ days`
|
|
||||||
: `${tier.minDays}–${tier.maxDays} days`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card key={tier.id} className="bg-background">
|
||||||
key={tier.id}
|
<CardContent className="pt-6">
|
||||||
className="flex flex-wrap items-center justify-between gap-4 border border-[var(--neutral-800)] rounded-xl p-4"
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
>
|
{/* Drag Handle & Remove */}
|
||||||
<div>
|
<div className="flex lg:flex-col items-center gap-2 lg:pt-6">
|
||||||
<div className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<GripVertical className="h-5 w-5 text-muted-foreground cursor-move" />
|
||||||
{tier.label}
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveTier(index)}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
disabled={editedTiers.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{rangeLabel}
|
{/* Tier Configuration */}
|
||||||
|
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`tier-label-${index}`}>Label</Label>
|
||||||
|
<Input
|
||||||
|
id={`tier-label-${index}`}
|
||||||
|
value={tier.label}
|
||||||
|
onChange={(e) => handleTierChange(index, 'label', e.target.value)}
|
||||||
|
placeholder="Tier Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Min Years */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`tier-min-${index}`}>Min Years</Label>
|
||||||
|
<Input
|
||||||
|
id={`tier-min-${index}`}
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
value={tier.minYears}
|
||||||
|
onChange={(e) => handleTierChange(index, 'minYears', parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Years */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`tier-max-${index}`}>Max Years</Label>
|
||||||
|
<Input
|
||||||
|
id={`tier-max-${index}`}
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
value={tier.maxYears}
|
||||||
|
onChange={(e) => handleTierChange(index, 'maxYears', parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`tier-icon-${index}`}>Icon</Label>
|
||||||
|
<Select
|
||||||
|
value={tier.iconKey}
|
||||||
|
onValueChange={(value) => handleTierChange(index, 'iconKey', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`tier-icon-${index}`}>
|
||||||
|
<SelectValue placeholder="Select icon" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIER_ICON_OPTIONS.map((option) => {
|
||||||
|
const OptionIcon = getTierIcon(option.key);
|
||||||
|
return (
|
||||||
|
<SelectItem key={option.key} value={option.key}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<OptionIcon className="h-4 w-4" />
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge Color */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor={`tier-badge-${index}`}>Badge Style</Label>
|
||||||
|
<Select
|
||||||
|
value={tier.badgeClass}
|
||||||
|
onValueChange={(value) => handleTierChange(index, 'badgeClass', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`tier-badge-${index}`}>
|
||||||
|
<SelectValue placeholder="Select color" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{BADGE_COLOR_PRESETS.map((preset) => (
|
||||||
|
<SelectItem key={preset.label} value={preset.badgeClass}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-4 h-4 rounded ${preset.badgeClass}`} />
|
||||||
|
{preset.label}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="space-y-2 md:col-span-2 flex flex-col">
|
||||||
|
<Label>Preview</Label>
|
||||||
|
<div className="flex-1 flex items-center">
|
||||||
|
<Badge className={`px-3 py-1.5 rounded-md text-sm flex items-center gap-2 border ${tier.badgeClass}`}>
|
||||||
|
<IconComponent className="h-4 w-4" />
|
||||||
|
{tier.label || 'Tier Name'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`px-3 py-1 rounded-md text-sm ${tier.badgeClass}`}>
|
</CardContent>
|
||||||
{tier.icon}
|
</Card>
|
||||||
</Badge>
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
{/* Add Tier Button */}
|
||||||
|
<Button variant="outline" onClick={handleAddTier} className="w-full border-dashed">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Tier
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card className="bg-muted/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
How Member Tiers Work
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>Tiers are matched based on membership duration in years</li>
|
||||||
|
<li>Each tier should have non-overlapping year ranges</li>
|
||||||
|
<li>The last tier typically uses a high max value (e.g., 999) to catch all long-term members</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Reset Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Reset Tiers to Defaults?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete all custom tier configurations and restore the default member tiers.
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleReset}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -223,10 +223,13 @@ const AdminNewsletters = () => {
|
|||||||
<Calendar className="h-5 w-5" />
|
<Calendar className="h-5 w-5" />
|
||||||
{year}
|
{year}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-3">
|
||||||
{groupedNewsletters[year].map(newsletter => (
|
{groupedNewsletters[year].map(newsletter => (
|
||||||
<Card key={newsletter.id} className="p-6">
|
<Card key={newsletter.id} className="p-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between ">
|
||||||
|
<div className="bg-light-lavender p-3 mr-4 rounded-xl self-center">
|
||||||
|
<FileText className="size-8 " />
|
||||||
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
||||||
{newsletter.title}
|
{newsletter.title}
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ import {
|
|||||||
PaginationEllipsis,
|
PaginationEllipsis,
|
||||||
} from '../../components/ui/pagination';
|
} from '../../components/ui/pagination';
|
||||||
import { toast } from 'sonner';
|
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 PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||||
import RejectionDialog from '../../components/RejectionDialog';
|
import RejectionDialog from '../../components/RejectionDialog';
|
||||||
import StatusBadge from '@/components/StatusBadge';
|
import StatusBadge from '@/components/StatusBadge';
|
||||||
|
import { StatCard } from '@/components/StatCard';
|
||||||
|
|
||||||
const AdminValidations = () => {
|
const AdminValidations = () => {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -272,45 +273,62 @@ const AdminValidations = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Stats Card */}
|
{/* Stats Card */}
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
<Card className="rounded-3xl bg-brand-lavender/10 p-8 mb-8">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
|
||||||
<div>
|
Quick Overview
|
||||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Pending</p>
|
</div>
|
||||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||||
{pendingUsers.length}
|
<StatCard
|
||||||
</p>
|
title="Total Pending"
|
||||||
</div>
|
value={loading ? '-' : pendingUsers.length}
|
||||||
<div>
|
icon={CheckCircle}
|
||||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Awaiting Email</p>
|
iconBgClass="text-brand-purple"
|
||||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
dataTestId="stat-total-users"
|
||||||
{pendingUsers.filter(u => u.status === 'pending_email').length}
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
<StatCard
|
||||||
<div>
|
title="Awaiting Email"
|
||||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p>
|
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
|
||||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
icon={CheckCircle}
|
||||||
{pendingUsers.filter(u => u.status === 'pending_validation').length}
|
iconBgClass="text-brand-purple"
|
||||||
</p>
|
dataTestId="stat-total-users"
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p>
|
<StatCard
|
||||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
title="Pending Validation"
|
||||||
{pendingUsers.filter(u => u.status === 'pre_validated').length}
|
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
|
||||||
</p>
|
icon={CheckCircle}
|
||||||
</div>
|
iconBgClass="text-brand-purple"
|
||||||
<div>
|
dataTestId="stat-pending-validation"
|
||||||
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
|
/>
|
||||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{pendingUsers.filter(u => u.status === 'payment_pending').length}
|
<StatCard
|
||||||
</p>
|
title="Pre-Validated"
|
||||||
</div>
|
value={loading ? '-' : pendingUsers.filter(u => u.status === 'pre_validated').length}
|
||||||
<div>
|
icon={CheckCircle}
|
||||||
<p className="text-sm text-red-600 mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Rejected</p>
|
iconBgClass="text-brand-purple"
|
||||||
<p className="text-3xl font-semibold text-red-800" style={{ fontFamily: "'Inter', sans-serif" }}>
|
dataTestId="stat-pre-validated"
|
||||||
{pendingUsers.filter(u => u.status === 'rejected').length}
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
<StatCard
|
||||||
|
title="Payment Pending"
|
||||||
|
value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
|
||||||
|
icon={CheckCircle}
|
||||||
|
iconBgClass="text-brand-purple"
|
||||||
|
dataTestId="stat-payment-pending"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
title="Rejected"
|
||||||
|
value={loading ? '-' : pendingUsers.filter(u => u.status === 'rejected').length}
|
||||||
|
icon={XCircle}
|
||||||
|
iconBgClass="text-red-600"
|
||||||
|
dataTestId="stat-rejected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
} from '../../components/ui/dialog';
|
} from '../../components/ui/dialog';
|
||||||
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
|
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import StatusBadge from '@/components/StatusBadge';
|
|
||||||
import MemberCard from '../../components/MemberCard';
|
import MemberCard from '../../components/MemberCard';
|
||||||
|
import MemberBadge from '../../components/MemberBadge';
|
||||||
import useMembers from '../../hooks/use-members';
|
import useMembers from '../../hooks/use-members';
|
||||||
|
import useMemberTiers from '../../hooks/use-member-tiers';
|
||||||
|
|
||||||
const MembersDirectory = () => {
|
const MembersDirectory = () => {
|
||||||
const [selectedMember, setSelectedMember] = useState(null);
|
const [selectedMember, setSelectedMember] = useState(null);
|
||||||
@@ -25,7 +26,27 @@ const MembersDirectory = () => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
|
const { tiers } = useMemberTiers();
|
||||||
const allowedRoles = useMemo(() => [], []);
|
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(
|
const searchAccessor = useCallback(
|
||||||
(member) => [
|
(member) => [
|
||||||
`${member.first_name} ${member.last_name}`,
|
`${member.first_name} ${member.last_name}`,
|
||||||
@@ -47,12 +68,14 @@ const MembersDirectory = () => {
|
|||||||
loading,
|
loading,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
filterValue,
|
||||||
} = useMembers({
|
} = useMembers({
|
||||||
endpoint: '/members/directory',
|
endpoint: '/members/directory',
|
||||||
initialFilter: 'active',
|
initialFilter: 'all',
|
||||||
filterKey: 'status',
|
filterKey: 'status',
|
||||||
allowedRoles,
|
allowedRoles,
|
||||||
searchAccessor,
|
searchAccessor,
|
||||||
|
transform: normalizeMembers,
|
||||||
fetchErrorMessage: 'Failed to load members directory. Please try again.',
|
fetchErrorMessage: 'Failed to load members directory. Please try again.',
|
||||||
onFetchError: handleFetchError
|
onFetchError: handleFetchError
|
||||||
});
|
});
|
||||||
@@ -67,7 +90,12 @@ const MembersDirectory = () => {
|
|||||||
|
|
||||||
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
|
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 ? (
|
) : filteredMembers.length > 0 ? (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{paginatedMembers.map((member) => (
|
{paginatedMembers.map((member) => (
|
||||||
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} />
|
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} tiers={tiers} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -210,7 +238,7 @@ const MembersDirectory = () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
{selectedMember.first_name} {selectedMember.last_name}
|
{selectedMember.first_name} {selectedMember.last_name}
|
||||||
<StatusBadge status={selectedMember.membership_status || selectedMember.status} />
|
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{selectedMember.directory_partner_name && (
|
{selectedMember.directory_partner_name && (
|
||||||
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
|||||||
@@ -167,9 +167,9 @@ export default function NewsletterArchive() {
|
|||||||
{groupedNewsletters[year].map(newsletter => (
|
{groupedNewsletters[year].map(newsletter => (
|
||||||
<Card key={newsletter.id} className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow">
|
<Card key={newsletter.id} className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl">
|
<div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl">
|
||||||
<FileText className="h-8 w-8 text-white" />
|
<FileText className="h-8 w-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
{newsletter.title}
|
{newsletter.title}
|
||||||
|
|||||||
@@ -1,23 +1,56 @@
|
|||||||
// src/utils/member-tiers.js
|
// src/utils/member-tiers.js
|
||||||
import { differenceInDays } from 'date-fns';
|
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;
|
if (!memberSince) return null;
|
||||||
const since = new Date(memberSince);
|
const since = new Date(memberSince);
|
||||||
if (Number.isNaN(since.getTime())) return null;
|
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) => {
|
export const getTierForMember = (memberSince, tiers = DEFAULT_MEMBER_TIERS) => {
|
||||||
const days = getTenureDays(memberSince);
|
const years = getTenureYears(memberSince);
|
||||||
if (days == null) return tiers[0];
|
if (years == null) return tiers[0];
|
||||||
|
|
||||||
const match = tiers.find(
|
const match = tiers.find(
|
||||||
(tier) =>
|
(tier) =>
|
||||||
days >= tier.minDays &&
|
years >= tier.minYears &&
|
||||||
(tier.maxDays == null || days <= tier.maxDays)
|
(tier.maxYears == null || years <= tier.maxYears)
|
||||||
);
|
);
|
||||||
|
|
||||||
return match || tiers[0];
|
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`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user