Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions frontend/app/admin/members/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
import { MemberStats } from "@/components/dashboard/admin/MemberStats";
import { MembersTable } from "@/components/dashboard/admin/MembersTable";
import { Search } from "lucide-react";

export default function AdminMembersPage() {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("ALL");
const [page, setPage] = useState(1);

return (
<DashboardLayout>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Members</h1>
<p className="text-gray-500 text-sm">Manage users and roles.</p>
</div>
<MemberStats />
<div className="bg-white rounded-xl border border-gray-100 mt-8 overflow-hidden">
<div className="p-4 flex gap-4 border-b border-gray-50">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input
value={search}
aria-label="Search members"
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full pl-10 pr-4 py-2 text-sm border rounded-lg outline-none focus:ring-1 focus:ring-gray-200"
placeholder="Search..."
/>
</div>
<select
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setPage(1);
}}
className="border rounded-lg px-3 py-2 text-sm outline-none"
aria-label="Filter members by status"
>
<option value="ALL">All</option>
<option value="ACTIVE">Active</option>
<option value="SUSPENDED">Suspended</option>
</select>
</div>
<MembersTable search={search} filter={filter} page={page} setPage={setPage} />
</div>
</DashboardLayout>
);
}
136 changes: 136 additions & 0 deletions frontend/components/dashboard/admin/ActionConfirmationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { Member } from "@/lib/types/member";
import {
MemberAction,
useMemberMutations,
} from "@/lib/react-query/hooks/useMemberMutations";

interface Props {
isOpen: boolean;
onClose: () => void;
member: Member;
action: MemberAction;
}

export function ActionConfirmationModal({ isOpen, onClose, member, action }: Props) {
const { mutate, isPending } = useMemberMutations();
const isMutating = isPending;
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const confirmButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
if (!isOpen) return;

const getFocusableElements = () => {
if (!modalRef.current) return [] as HTMLElement[];
const selectors =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(modalRef.current.querySelectorAll<HTMLElement>(selectors));
};

const focusableElements = getFocusableElements();
(focusableElements[0] ?? confirmButtonRef.current)?.focus();

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
return;
}

if (event.key !== "Tab") return;

const elements = getFocusableElements();
if (elements.length === 0) return;

const firstElement = elements[0];
const lastElement = elements[elements.length - 1];
const activeElement = document.activeElement as HTMLElement | null;

if (event.shiftKey) {
if (activeElement === firstElement || !activeElement) {
event.preventDefault();
lastElement.focus();
}
return;
}

if (activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
};

document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, onClose]);

if (!isOpen) return null;

const handleConfirm = () => {
setErrorMessage(null);
mutate(
{ memberId: member.id, action },
{
onSuccess: () => onClose(),
onError: (error) => {
const message =
error instanceof Error
? error.message
: "Failed to update member. Please try again.";
setErrorMessage(message);
},
}
);
};

return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirmHeading"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
>
<div className="bg-white rounded-2xl max-w-sm w-full p-6 shadow-2xl animate-in fade-in zoom-in duration-200">
<h3 id="confirmHeading" className="text-lg font-bold text-gray-900 capitalize">
Confirm {action}
</h3>
<p className="text-gray-500 text-sm mt-3 leading-relaxed">
Are you sure you want to {action.toLowerCase()} <strong>{member.firstName}</strong>?
This will take effect immediately.
</p>
{errorMessage && (
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
{errorMessage}
</p>
)}
<div className="mt-8 flex gap-3">
<button
type="button"
onClick={onClose}
disabled={isMutating || isPending}
className={`flex-1 px-4 py-2.5 text-sm font-medium border border-gray-200 rounded-xl transition-colors ${
isMutating || isPending ? "opacity-50 cursor-not-allowed" : "hover:bg-gray-50"
}`}
>
Cancel
</button>
<button
ref={confirmButtonRef}
type="button"
onClick={handleConfirm}
disabled={isPending}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-gray-900 rounded-xl disabled:opacity-50 hover:bg-black transition-all"
>
{isPending ? "Updating..." : "Confirm"}
</button>
</div>
</div>
</div>
);
}
56 changes: 56 additions & 0 deletions frontend/components/dashboard/admin/MemberActionButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { Member } from "@/lib/types/member";
import { ActionConfirmationModal } from "./ActionConfirmationModal";
import { MemberAction } from "@/lib/react-query/hooks/useMemberMutations";

export function MemberActionButtons({ member }: { member: Member }) {
const [modal, setModal] = useState<{ open: boolean; action: MemberAction | null }>({
open: false,
action: null,
});
if (["ADMIN", "SUPER_ADMIN"].includes(member.role)) return <span className="text-gray-300 italic">Read-only</span>;

const open = (action: MemberAction) => setModal({ open: true, action });

return (
<>
<div className="flex justify-end gap-3 text-xs font-bold uppercase tracking-wider">
{member.status === "ACTIVE" ? (
<>
<button
type="button"
onClick={() => open("SUSPEND")}
className="text-red-500 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
>
Suspend
</button>
<button
type="button"
onClick={() => open(member.role === "USER" ? "PROMOTE" : "DEMOTE")}
className="text-gray-900 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
>
{member.role === "USER" ? "Promote" : "Demote"}
</button>
</>
) : (
<button
type="button"
onClick={() => open("ACTIVATE")}
className="text-emerald-600 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
>
Activate
</button>
)}
</div>
{modal.open && modal.action && (
<ActionConfirmationModal
isOpen={modal.open}
onClose={() => setModal({ ...modal, open: false })}
member={member}
action={modal.action}
/>
)}
</>
);
}
71 changes: 71 additions & 0 deletions frontend/components/dashboard/admin/MemberStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import { useGetMemberStats } from "@/lib/react-query/hooks/members"; // Ensure hook path

export function MemberStats() {
const {
data: stats,
isLoading,
isError,
error,
refetch,
} = useGetMemberStats();

if (isLoading) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white p-5 rounded-xl border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Loading</p>
<div className="h-8 w-16 bg-gray-100 animate-pulse mt-2 rounded" />
</div>
))}
</div>
);
}

if (isError) {
const message =
error instanceof Error
? error.message
: "Could not load member stats right now.";

return (
<div className="rounded-xl border border-red-200 bg-red-50 p-5">
<p className="text-sm font-semibold text-red-700">Unable to load member stats</p>
<p className="mt-1 text-sm text-red-600">{message}</p>
<button
type="button"
onClick={() => refetch()}
className="mt-3 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
>
Retry
</button>
</div>
);
}

if (!stats) {
return null;
}

const cards = [
{ label: "Total Members", value: stats.total },
{ label: "Active", value: stats.active },
{ label: "Suspended", value: stats.suspended },
{ label: "New This Month", value: stats.newThisMonth },
];

return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card, i) => (
<div key={i} className="bg-white p-5 rounded-xl border border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
{card.label}
</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{card.value}</p>
</div>
))}
</div>
);
}
Loading
Loading