370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import api from '../../utils/api';
|
|
import { Card } from '../../components/ui/card';
|
|
import { Button } from '../../components/ui/button';
|
|
import { Badge } from '../../components/ui/badge';
|
|
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
|
|
|
const AdminUserView = () => {
|
|
const { userId } = useParams();
|
|
const navigate = useNavigate();
|
|
const [user, setUser] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
|
const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
|
|
const [subscriptions, setSubscriptions] = useState([]);
|
|
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true);
|
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
|
const [pendingAction, setPendingAction] = useState(null);
|
|
|
|
useEffect(() => {
|
|
fetchUserProfile();
|
|
fetchSubscriptions();
|
|
}, [userId]);
|
|
|
|
const fetchUserProfile = async () => {
|
|
try {
|
|
const response = await api.get(`/admin/users/${userId}`);
|
|
setUser(response.data);
|
|
} catch (error) {
|
|
toast.error('Failed to load user profile');
|
|
navigate('/admin/members');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchSubscriptions = async () => {
|
|
try {
|
|
const response = await api.get(`/admin/subscriptions?user_id=${userId}`);
|
|
setSubscriptions(response.data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch subscriptions:', error);
|
|
} finally {
|
|
setSubscriptionsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResetPasswordRequest = () => {
|
|
setPendingAction({ type: 'reset_password' });
|
|
setConfirmDialogOpen(true);
|
|
};
|
|
|
|
const handleResendVerificationRequest = () => {
|
|
setPendingAction({ type: 'resend_verification' });
|
|
setConfirmDialogOpen(true);
|
|
};
|
|
|
|
const confirmAction = async () => {
|
|
if (!pendingAction) return;
|
|
|
|
const { type } = pendingAction;
|
|
setConfirmDialogOpen(false);
|
|
|
|
if (type === 'reset_password') {
|
|
setResetPasswordLoading(true);
|
|
try {
|
|
await api.put(`/admin/users/${userId}/reset-password`, {
|
|
force_change: true
|
|
});
|
|
toast.success(`Password reset email sent to ${user.email}`);
|
|
} catch (error) {
|
|
const errorMessage = error.response?.data?.detail || 'Failed to reset password';
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setResetPasswordLoading(false);
|
|
setPendingAction(null);
|
|
}
|
|
} else if (type === 'resend_verification') {
|
|
setResendVerificationLoading(true);
|
|
try {
|
|
await api.post(`/admin/users/${userId}/resend-verification`);
|
|
toast.success(`Verification email sent to ${user.email}`);
|
|
// Refresh user data to get updated email_verified status if changed
|
|
await fetchUserProfile();
|
|
} catch (error) {
|
|
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setResendVerificationLoading(false);
|
|
setPendingAction(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getActionMessage = () => {
|
|
if (!pendingAction || !user) return {};
|
|
|
|
const { type } = pendingAction;
|
|
const userName = `${user.first_name} ${user.last_name}`;
|
|
|
|
if (type === 'reset_password') {
|
|
return {
|
|
title: 'Reset Password?',
|
|
description: `This will send a temporary password to ${user.email}. ${userName} will be required to change it on their next login.`,
|
|
variant: 'warning',
|
|
confirmText: 'Yes, Reset Password',
|
|
};
|
|
}
|
|
|
|
if (type === 'resend_verification') {
|
|
return {
|
|
title: 'Resend Verification Email?',
|
|
description: `This will send a new verification email to ${user.email}. ${userName} will need to click the link to verify their email address.`,
|
|
variant: 'info',
|
|
confirmText: 'Yes, Resend Email',
|
|
};
|
|
}
|
|
|
|
return {};
|
|
};
|
|
|
|
if (loading) return <div>Loading...</div>;
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Back Button */}
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => navigate(-1)}
|
|
className="mb-6"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
|
|
{/* User Profile Header */}
|
|
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
|
<div className="flex items-start gap-6">
|
|
{/* Avatar */}
|
|
<div className="h-24 w-24 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-3xl">
|
|
{user.first_name?.[0]}{user.last_name?.[0]}
|
|
</div>
|
|
|
|
{/* User Info */}
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{user.first_name} {user.last_name}
|
|
</h1>
|
|
{/* Status & Role Badges */}
|
|
<Badge>{user.status}</Badge>
|
|
<Badge>{user.role}</Badge>
|
|
</div>
|
|
|
|
{/* Contact Info */}
|
|
<div className="grid md:grid-cols-2 gap-4 text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="h-4 w-4" />
|
|
<span>{user.email}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Phone className="h-4 w-4" />
|
|
<span>{user.phone}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="h-4 w-4" />
|
|
<span>{user.city}, {user.state} {user.zipcode}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="h-4 w-4" />
|
|
<span>Joined {new Date(user.created_at).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Admin Actions */}
|
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
|
<h2 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Admin Actions
|
|
</h2>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button
|
|
onClick={handleResetPasswordRequest}
|
|
disabled={resetPasswordLoading}
|
|
variant="outline"
|
|
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-4 py-2 disabled:opacity-50"
|
|
>
|
|
<Lock className="h-4 w-4 mr-2" />
|
|
{resetPasswordLoading ? 'Resetting...' : 'Reset Password'}
|
|
</Button>
|
|
|
|
{!user.email_verified && (
|
|
<Button
|
|
onClick={handleResendVerificationRequest}
|
|
disabled={resendVerificationLoading}
|
|
variant="outline"
|
|
className="border-2 border-[#ff9e77] text-[#ff9e77] hover:bg-[#FFF3E0] rounded-full px-4 py-2 disabled:opacity-50"
|
|
>
|
|
<Mail className="h-4 w-4 mr-2" />
|
|
{resendVerificationLoading ? 'Sending...' : 'Resend Verification Email'}
|
|
</Button>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 text-sm text-[#664fa3] ml-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<span>User will receive a temporary password via email</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Additional Details */}
|
|
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb]">
|
|
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Additional Information
|
|
</h2>
|
|
|
|
<div className="grid md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</label>
|
|
<p className="text-[#422268] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.address}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</label>
|
|
<p className="text-[#422268] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{new Date(user.date_of_birth).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
|
|
{user.partner_first_name && (
|
|
<div>
|
|
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Partner</label>
|
|
<p className="text-[#422268] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{user.partner_first_name} {user.partner_last_name}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{user.referred_by_member_name && (
|
|
<div>
|
|
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Referred By</label>
|
|
<p className="text-[#422268] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user.referred_by_member_name}</p>
|
|
</div>
|
|
)}
|
|
|
|
{user.lead_sources && user.lead_sources.length > 0 && (
|
|
<div className="md:col-span-2">
|
|
<label className="text-sm font-medium text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Lead Sources</label>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{user.lead_sources.map((source, idx) => (
|
|
<Badge key={idx} variant="outline">{source}</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Subscription Info (if applicable) */}
|
|
{user.role === 'member' && (
|
|
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] mt-8">
|
|
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Subscription Information
|
|
</h2>
|
|
|
|
{subscriptionsLoading ? (
|
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading subscriptions...</p>
|
|
) : subscriptions.length === 0 ? (
|
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No subscriptions found for this member.</p>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{subscriptions.map((sub) => (
|
|
<div key={sub.id} className="p-6 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb]">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{sub.plan.name}
|
|
</h3>
|
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{sub.plan.billing_cycle}
|
|
</p>
|
|
</div>
|
|
<Badge className={
|
|
sub.status === 'active' ? 'bg-[#81B29A] text-white' :
|
|
sub.status === 'expired' ? 'bg-red-500 text-white' :
|
|
'bg-gray-400 text-white'
|
|
}>
|
|
{sub.status}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label>
|
|
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{new Date(sub.start_date).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
{sub.end_date && (
|
|
<div>
|
|
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label>
|
|
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{new Date(sub.end_date).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Base Amount</label>
|
|
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
${(sub.base_subscription_cents / 100).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
{sub.donation_cents > 0 && (
|
|
<div>
|
|
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Donation</label>
|
|
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
${(sub.donation_cents / 100).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Paid</label>
|
|
<p className="text-[#422268] font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
${(sub.amount_paid_cents / 100).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
{sub.payment_method && (
|
|
<div>
|
|
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</label>
|
|
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{sub.payment_method}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{sub.stripe_subscription_id && (
|
|
<div className="md:col-span-2">
|
|
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Stripe Subscription ID</label>
|
|
<p className="text-[#422268] text-xs font-mono" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{sub.stripe_subscription_id}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
{/* Admin Action Confirmation Dialog */}
|
|
<ConfirmationDialog
|
|
open={confirmDialogOpen}
|
|
onOpenChange={setConfirmDialogOpen}
|
|
onConfirm={confirmAction}
|
|
loading={resetPasswordLoading || resendVerificationLoading}
|
|
{...getActionMessage()}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default AdminUserView;
|