7 Commits

14 changed files with 442 additions and 263 deletions

View File

@@ -224,7 +224,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
${item.disabled ${item.disabled
? 'opacity-50 cursor-not-allowed text-brand-purple ' ? 'opacity-50 cursor-not-allowed text-brand-purple '
: active : active
? 'bg-[var(--orange-light)]/10 text-[var(--orange-light)]' ? 'bg-[var(--orange-light)]/10 text-[var(--purple-ink)]'
: 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/20' : 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/20'
} }
`} `}
@@ -254,7 +254,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* Badge when collapsed */} {/* Badge when collapsed */}
{!isOpen && item.badge > 0 && !item.disabled && ( {!isOpen && item.badge > 0 && !item.disabled && (
<div className="absolute -top-1 -right-1 bg-accent foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium"> <div className="absolute -top-1 -right-1 bg-accent text-white foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
{item.badge} {item.badge}
</div> </div>
)} )}

View File

@@ -31,6 +31,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const getTodayDate = () => new Date().toISOString().slice(0, 10);
const handleChange = (field, value) => { const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
@@ -84,8 +85,8 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
if (payload.date_of_birth === '') { if (payload.date_of_birth === '') {
delete payload.date_of_birth; delete payload.date_of_birth;
} }
if (payload.member_since === '') { if (!payload.member_since) {
delete payload.member_since; payload.member_since = getTodayDate();
} }
await api.post('/admin/users/create', payload); await api.post('/admin/users/create', payload);

View File

@@ -22,10 +22,12 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
first_name: '', first_name: '',
last_name: '', last_name: '',
phone: '', phone: '',
member_since: '',
role: 'admin' role: 'admin'
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const getTodayDate = () => new Date().toISOString().slice(0, 10);
const handleChange = (field, value) => { const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
@@ -74,7 +76,11 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
setLoading(true); setLoading(true);
try { try {
await api.post('/admin/users/create', formData); const payload = { ...formData };
if (!payload.member_since) {
payload.member_since = getTodayDate();
}
await api.post('/admin/users/create', payload);
toast.success('Staff member created successfully'); toast.success('Staff member created successfully');
// Reset form // Reset form
@@ -84,6 +90,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
first_name: '', first_name: '',
last_name: '', last_name: '',
phone: '', phone: '',
member_since: '',
role: 'admin' role: 'admin'
}); });
@@ -200,6 +207,20 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
)} )}
</div> </div>
{/* Member Since */}
<div className="grid gap-2">
<Label htmlFor="member_since" className="text-[var(--purple-ink)]">
Member Since
</Label>
<Input
id="member_since"
type="date"
value={formData.member_since}
onChange={(e) => handleChange('member_since', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
/>
</div>
{/* Role */} {/* Role */}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="role" className="text-[var(--purple-ink)]"> <Label htmlFor="role" className="text-[var(--purple-ink)]">

View File

@@ -0,0 +1,36 @@
import React from "react";
import { Card } from "./ui/card";
export const StatCard = ({
title,
value,
icon: Icon,
iconBgClass,
dataTestId,
}) => (
<Card
className="p-6 flex flex-col justify-between bg-background rounded-2xl border border-[var(--neutral-800)]"
data-testid={dataTestId}
>
<div className="flex items-start gap-4 mb-4 ">
<div className={`${iconBgClass} p-3 rounded-lg `}>
<Icon className="size-8" />
</div>
<div className="space-y-8">
<p
className="text-6xl font-semibold text-[var(--purple-ink)] mb-1"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{value}
</p>
</div>
</div>
<p
className="text-sm text-brand-purple "
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{title}
</p>
</Card>
);

View File

@@ -1,50 +1,65 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Card = React.forwardRef(({ className, ...props }, ref) => ( const Card = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} className={cn(
{...props} /> "rounded-xl border bg-card text-card-foreground shadow",
)) className,
Card.displayName = "Card" )}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} /> {...props}
)) />
CardHeader.displayName = "CardHeader" ));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)} className={cn("font-semibold leading-none tracking-tight", className)}
{...props} /> {...props}
)) />
CardTitle.displayName = "CardTitle" ));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} /> {...props}
)) />
CardDescription.displayName = "CardDescription" ));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef(({ className, ...props }, ref) => ( const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)) ));
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} /> {...props}
)) />
CardFooter.displayName = "CardFooter" ));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -17,6 +17,7 @@ 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 joinedDate = user?.member_since || user?.created_at;
useEffect(() => { useEffect(() => {
fetchUpcomingEvents(); fetchUpcomingEvents();
@@ -197,7 +198,7 @@ const Dashboard = () => {
<div> <div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</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" }}>
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'} {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
</p> </p>
</div> </div>
{user?.subscription_start_date && user?.subscription_end_date && ( {user?.subscription_start_date && user?.subscription_end_date && (

View File

@@ -258,7 +258,7 @@ const Register = () => {
<Button <Button
type="button" type="button"
onClick={handleNext} onClick={handleNext}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform" className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform"
> >
Next Next
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />
@@ -267,7 +267,7 @@ const Register = () => {
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed" className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-backgroundrounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="submit-register-button" data-testid="submit-register-button"
> >
{loading ? 'Creating Account...' : 'Create Account'} {loading ? 'Creating Account...' : 'Create Account'}

View File

@@ -4,13 +4,16 @@ import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle, Globe } from 'lucide-react'; import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle, Globe, CircleMinus } from 'lucide-react';
import { StatCard } from '../../components/StatCard';
const AdminDashboard = () => { const AdminDashboard = () => {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
totalMembers: 0, totalMembers: 0,
pendingValidations: 0, pendingValidations: 0,
activeMembers: 0 activeMembers: 0,
inactiveMembers: 0
}); });
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]); const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -29,7 +32,8 @@ const AdminDashboard = () => {
pendingValidations: users.filter(u => pendingValidations: users.filter(u =>
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status) ['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
).length, ).length,
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length,
inactiveMembers: users.filter(u => u.status === 'inactive' && u.role === 'member').length
}); });
// Find users who have received 3+ reminders (may need personal outreach) // Find users who have received 3+ reminders (may need personal outreach)
@@ -76,52 +80,42 @@ const AdminDashboard = () => {
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-total-users"> <div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<div className='flex items-start justify-between'> <div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> Quick Overview
{loading ? '-' : stats.totalMembers}
</p>
<div className="flex items-start justify-between mb-4">
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-brand-purple" />
</div>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 ">
<StatCard
title="Total Members"
value={loading ? '-' : stats.totalMembers}
icon={Users}
iconBgClass="text-brand-purple"
dataTestId="stat-total-users"
/>
<StatCard
title="Pending Validations"
value={loading ? '-' : stats.pendingValidations}
icon={Clock}
iconBgClass="text-brand-light-orange"
dataTestId="stat-total-users"
/>
<StatCard
title="Active Members"
value={loading ? '-' : stats.activeMembers}
icon={CheckCircle}
iconBgClass="text-[var(--green-light)]"
dataTestId="stat-total-users"
/>
<StatCard
title="Inactive Members"
value={loading ? '-' : stats.inactiveMembers}
icon={CircleMinus}
iconBgClass="text-brand-pink"
dataTestId="stat-total-users"
/>
</div> </div>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-pending-validations">
<div className='flex items-start justify-between'>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.pendingValidations}
</p>
<div className="flex items-start justify-between mb-4">
<div className=" p-3 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" />
</div>
</div>
</div>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-active-members">
<div className='flex items-start justify-between'>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.activeMembers}
</p>
<div className="flex items-start justify-between mb-4">
<div className="bg-[var(--green-light)]/20 p-3 rounded-lg">
<CheckCircle className="h-6 w-6 text-[var(--green-light)]" />
</div>
</div>
</div>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
</Card>
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}

View File

@@ -14,12 +14,13 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown } from 'lucide-react'; import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog'; import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog'; import InviteStaffDialog from '../../components/InviteStaffDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard'; import WordPressImportWizard from '../../components/WordPressImportWizard';
import { StatCard } from '@/components/StatCard';
const AdminMembers = () => { const AdminMembers = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -319,31 +320,45 @@ const AdminMembers = () => {
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p> Quick Overview
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> </div>
{users.length} <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <StatCard
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p> title="Total Members"
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={users.length}
{users.filter(u => u.status === 'active').length} icon={Users}
</p> iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
</Card> dataTestId="stat-total-members"
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> />
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p> <StatCard
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> title="Active"
{users.filter(u => u.status === 'payment_pending').length} value={users.filter(u => u.status === 'active').length}
</p> icon={CheckCircle}
</Card> iconBgClass="text-[var(--green-light)]"
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> dataTestId="stat-active-members"
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Inactive</p> />
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <StatCard
{users.filter(u => u.status === 'inactive').length} title="Payment Pending"
</p> value={users.filter(u => u.status === 'payment_pending').length}
</Card> icon={CreditCard}
iconBgClass="text-brand-light-orange"
dataTestId="stat-payment-pending-members"
/>
<StatCard
title="Inactive"
value={users.filter(u => u.status === 'inactive').length}
icon={CircleMinus}
iconBgClass=" text-brand-pink"
dataTestId="stat-inactive-members"
/>
</div>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -385,7 +400,9 @@ const AdminMembers = () => {
</div> </div>
) : filteredUsers.length > 0 ? ( ) : filteredUsers.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{filteredUsers.map((user) => ( {filteredUsers.map((user) => {
const joinedDate = user.member_since || user.created_at;
return (
<Card <Card
key={user.id} key={user.id}
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow" className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow"
@@ -409,7 +426,7 @@ const AdminMembers = () => {
<div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple dark:text-brand-lavender " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple dark:text-brand-lavender " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p> <p>Email: {user.email}</p>
<p>Phone: {user.phone}</p> <p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p> <p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
{user.referred_by_member_name && ( {user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p> <p>Referred by: {user.referred_by_member_name}</p>
)} )}
@@ -523,7 +540,8 @@ const AdminMembers = () => {
</div> </div>
</div> </div>
</Card> </Card>
))} );
})}
</div> </div>
) : ( ) : (
<div className="text-center py-20"> <div className="text-center py-20">

View File

@@ -242,7 +242,9 @@ const AdminStaff = () => {
</div> </div>
) : filteredUsers.length > 0 ? ( ) : filteredUsers.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{filteredUsers.map((user) => ( {filteredUsers.map((user) => {
const joinedDate = user.member_since || user.created_at;
return (
<Card <Card
key={user.id} key={user.id}
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow" className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow"
@@ -267,7 +269,7 @@ const AdminStaff = () => {
<div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p> <p>Email: {user.email}</p>
<p>Phone: {user.phone}</p> <p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p> <p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
{user.last_login && ( {user.last_login && (
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p> <p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
)} )}
@@ -322,7 +324,8 @@ const AdminStaff = () => {
</div> </div>
</div> </div>
</Card> </Card>
))} );
})}
</div> </div>
) : ( ) : (
<div className="text-center py-20"> <div className="text-center py-20">

View File

@@ -5,6 +5,7 @@ import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '../../components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '../../components/ui/avatar';
import { Input } from '../../components/ui/input';
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera, Upload, Trash2, Shield } from 'lucide-react'; import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera, Upload, Trash2, Shield } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
@@ -24,15 +25,58 @@ const AdminUserView = () => {
const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
const [memberSince, setMemberSince] = useState('');
const [memberSinceSaving, setMemberSinceSaving] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const [changeRoleDialogOpen, setChangeRoleDialogOpen] = useState(false); const [changeRoleDialogOpen, setChangeRoleDialogOpen] = useState(false);
const formatLocalDateInputValue = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatDateInputValue = (value) => {
if (!value) return '';
if (typeof value === 'string') {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value.slice(0, 10);
}
return formatLocalDateInputValue(parsed);
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return formatLocalDateInputValue(parsed);
};
const formatDateDisplayValue = (value) => {
if (!value) return 'N/A';
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day).toLocaleDateString();
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return 'N/A';
return parsed.toLocaleDateString();
};
useEffect(() => { useEffect(() => {
fetchConfig(); fetchConfig();
fetchUserProfile(); fetchUserProfile();
fetchSubscriptions(); fetchSubscriptions();
}, [userId]); }, [userId]);
useEffect(() => {
if (user) {
setMemberSince(formatDateInputValue(user.member_since));
}
}, [user]);
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
try { try {
const response = await api.get(`/admin/users/${userId}`); const response = await api.get(`/admin/users/${userId}`);
@@ -177,6 +221,27 @@ const AdminUserView = () => {
} }
}; };
const handleMemberSinceSave = async () => {
if (!user) return;
setMemberSinceSaving(true);
try {
const payload = {
member_since: memberSince ? memberSince : null
};
const response = await api.put(`/admin/users/${userId}`, payload);
setUser(prev => ({
...prev,
...(response?.data || {}),
member_since: payload.member_since
}));
toast.success('Member since updated successfully');
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update member since');
} finally {
setMemberSinceSaving(false);
}
};
const getActionMessage = () => { const getActionMessage = () => {
if (!pendingAction || !user) return {}; if (!pendingAction || !user) return {};
@@ -212,6 +277,10 @@ const AdminUserView = () => {
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
if (!user) return null; if (!user) return null;
const joinedDate = user.member_since || user.created_at;
const memberSinceBaseline = formatDateInputValue(user.member_since);
const memberSinceHasChanges = memberSince !== memberSinceBaseline;
return ( return (
<> <>
{/* Back Button */} {/* Back Button */}
@@ -262,7 +331,7 @@ const AdminUserView = () => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span>Joined {new Date(user.created_at).toLocaleDateString()}</span> <span>Joined {formatDateDisplayValue(joinedDate)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -359,10 +428,31 @@ const AdminUserView = () => {
<div> <div>
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</label> <label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</label>
<p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--purple-ink)] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(user.date_of_birth).toLocaleDateString()} {formatDateDisplayValue(user.date_of_birth)}
</p> </p>
</div> </div>
<div>
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</label>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Input
type="date"
value={memberSince}
onChange={(e) => setMemberSince(e.target.value)}
className="max-w-[200px] border-[var(--neutral-800)]"
/>
<Button
type="button"
size="sm"
onClick={handleMemberSinceSave}
disabled={memberSinceSaving || !memberSinceHasChanges}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
>
{memberSinceSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
{user.partner_first_name && ( {user.partner_first_name && (
<div> <div>
<label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Partner</label> <label className="text-sm font-medium text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Partner</label>
@@ -429,14 +519,14 @@ const AdminUserView = () => {
<div> <div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label> <label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(sub.start_date).toLocaleDateString()} {formatDateDisplayValue(sub.start_date)}
</p> </p>
</div> </div>
{sub.end_date && ( {sub.end_date && (
<div> <div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label> <label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(sub.end_date).toLocaleDateString()} {formatDateDisplayValue(sub.end_date)}
</p> </p>
</div> </div>
)} )}

View File

@@ -118,7 +118,9 @@ const MembersDirectory = () => {
: <div className=' border-2 w-full border-brand-purple mb-24' /> : <div className=' border-2 w-full border-brand-purple mb-24' />
) )
} }
const MemberCard = ({ member }) => ( const MemberCard = ({ member }) => {
const joinedDate = member.member_since || member.created_at;
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 */} {/* Profile Photo */}
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
@@ -160,11 +162,11 @@ const MembersDirectory = () => {
)} )}
{/* Member Since */} {/* Member Since */}
{member.created_at && ( {joinedDate && (
<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(member.created_at).toLocaleDateString('en-US', { Member since {new Date(joinedDate).toLocaleDateString('en-US', {
month: 'long', month: 'long',
year: 'numeric' year: 'numeric'
})} })}
@@ -277,6 +279,7 @@ const MembersDirectory = () => {
</div> </div>
</Card> </Card>
); );
};
return ( return (
<div className="min-h-screen bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)]"> <div className="min-h-screen bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)]">

View File

@@ -17,7 +17,7 @@
} }
.btn-ghost { .btn-ghost {
@apply hover:bg-brand-purple bg-brand-purple/10 rounded-full disabled:opacity-50 px-6 transition-transform text-brand-purple hover:text-[var(--purple-muted)]; @apply hover:bg-brand-purple bg-brand-purple/10 rounded-full disabled:opacity-50 px-6 transition-transform text-brand-purple hover:text-background;
} }
.btn-outline { .btn-outline {
@@ -56,10 +56,10 @@
@apply h-9 w-9 rounded-full disabled:opacity-50 px-6; @apply h-9 w-9 rounded-full disabled:opacity-50 px-6;
} }
.btn-green { .btn-green {
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-full px-6 ; @apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-full px-6;
} }
.btn-util-green { .btn-util-green {
@apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-xl h-12 px-6 ; @apply bg-[var(--green-light)] hover:bg-[var(--green-forest)] text-white transition-transform rounded-xl h-12 px-6;
} }
.btn-util-purple { .btn-util-purple {
@@ -76,7 +76,7 @@
@apply bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2 dark:hover:bg-foreground dark:hover:text-background; @apply bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full flex items-center gap-2 dark:hover:bg-foreground dark:hover:text-background;
} }
.btn-light-lavender { .btn-light-lavender {
@apply bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-lavender rounded-full px-6 transition-transform dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender ; @apply bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-lavender rounded-full px-6 transition-transform dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender;
} }
/* Badges */ /* Badges */
@@ -91,6 +91,4 @@
.bg-light-lavender { .bg-light-lavender {
@apply bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] text-white bg-[var(--neutral-800)] transition-transform dark:bg-brand-lavender dark:text-brand-dark-lavender; @apply bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] text-white bg-[var(--neutral-800)] transition-transform dark:bg-brand-lavender dark:text-brand-dark-lavender;
} }
} }

View File

@@ -78,7 +78,7 @@
--green-muted: #66927e; --green-muted: #66927e;
--green-soft: #6a9680; --green-soft: #6a9680;
--green-eucalyptus: #6a9a83; --green-eucalyptus: #6a9a83;
--green-forest: #5e8EsOkvcwL7472374; --green-forest: #5a7d68;
--green-fern: #6da085; --green-fern: #6da085;
--green-mint: #6fa087; --green-mint: #6fa087;
--green-pastel: #6fa188; --green-pastel: #6fa188;
@@ -243,4 +243,3 @@
--neutral-900: #f4f4ff; /* highest contrast text */ --neutral-900: #f4f4ff; /* highest contrast text */
} }
} }