Initial commit
This commit is contained in:
5
app/admin/analytics/page.tsx
Normal file
5
app/admin/analytics/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminPage from "../page";
|
||||
|
||||
export default function AdminAnalyticsPage() {
|
||||
return <AdminPage />;
|
||||
}
|
||||
111
app/admin/contact-messages/page.tsx
Normal file
111
app/admin/contact-messages/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface ContactMessage {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function AdminContactMessagesPage() {
|
||||
const [messages, setMessages] = useState<ContactMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, []);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/contact-messages");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMessages(data.messages || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch messages:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Contact Messages
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Review and respond to customer inquiries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block w-8 h-8 border-4 border-primary border-r-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading messages...</p>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<div className="text-6xl mb-4">📧</div>
|
||||
<p className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No messages yet
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Customer inquiries will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="card p-6 hover:shadow-elevation-2 transition-all">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{message.subject}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
From: {message.name} ({message.email})
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{new Date(message.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
message.status === "NEW"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: message.status === "READ"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
}`}
|
||||
>
|
||||
{message.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-slate-800 rounded-lg p-4 mb-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{message.message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`mailto:${message.email}?subject=Re: ${message.subject}`}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
📧 Reply via Email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
26
app/admin/layout.tsx
Normal file
26
app/admin/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getSession } from "../../lib/auth/session";
|
||||
import { redirect } from "next/navigation";
|
||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-darkbg">
|
||||
<AdminSidebar userName={session.email?.split('@')[0]} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
app/admin/page.tsx
Normal file
233
app/admin/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface Stats {
|
||||
totalUsers: number;
|
||||
totalWebinars: number;
|
||||
totalRegistrations: number;
|
||||
revenue: number;
|
||||
upcomingWebinars: number;
|
||||
}
|
||||
|
||||
interface Webinar {
|
||||
id: string;
|
||||
title: string;
|
||||
startAt: string;
|
||||
speaker: string;
|
||||
}
|
||||
|
||||
interface Registration {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
user: { firstName: string; lastName: string; email: string };
|
||||
webinar: { title: string };
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
totalUsers: 0,
|
||||
totalWebinars: 0,
|
||||
totalRegistrations: 0,
|
||||
revenue: 0,
|
||||
upcomingWebinars: 0,
|
||||
});
|
||||
const [upcomingWebinars, setUpcomingWebinars] = useState<Webinar[]>([]);
|
||||
const [recentRegistrations, setRecentRegistrations] = useState<Registration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const [webinarsRes, registrationsRes] = await Promise.all([
|
||||
fetch("/api/webinars?limit=100"),
|
||||
fetch("/api/registrations?limit=10"),
|
||||
]);
|
||||
|
||||
const webinarsData = await webinarsRes.ok ? await webinarsRes.json() : { webinars: [] };
|
||||
const registrationsData = await registrationsRes.ok ? await registrationsRes.json() : { registrations: [] };
|
||||
|
||||
const now = new Date();
|
||||
const upcoming = webinarsData.webinars?.filter((w: Webinar) => new Date(w.startAt) > now) || [];
|
||||
const revenue = registrationsData.registrations?.reduce(
|
||||
(sum: number, reg: any) => sum + (reg.webinar?.priceCents || 0),
|
||||
0
|
||||
) || 0;
|
||||
|
||||
// Get unique users from registrations
|
||||
const uniqueUsers = new Set(registrationsData.registrations?.map((r: any) => r.userId) || []);
|
||||
|
||||
setStats({
|
||||
totalUsers: uniqueUsers.size,
|
||||
totalWebinars: webinarsData.webinars?.length || 0,
|
||||
totalRegistrations: registrationsData.registrations?.length || 0,
|
||||
revenue: revenue / 100,
|
||||
upcomingWebinars: upcoming.length,
|
||||
});
|
||||
|
||||
setUpcomingWebinars(upcoming.slice(0, 5));
|
||||
setRecentRegistrations(registrationsData.registrations?.slice(0, 5) || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block w-8 h-8 border-4 border-primary border-r-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2 text-slate-900 dark:text-white">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Welcome back, suman. Here's what's happening.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-600 dark:text-gray-400">Total Webinars</h3>
|
||||
<div className="h-10 w-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<span className="text-xl">📹</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-slate-900 dark:text-white mb-1">
|
||||
{stats.totalWebinars}
|
||||
</div>
|
||||
<p className="text-xs text-primary flex items-center gap-1">
|
||||
<span className="inline-flex items-center">⏱️</span>
|
||||
{stats.upcomingWebinars} upcoming
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-600 dark:text-gray-400">Total Users</h3>
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<span className="text-xl">👥</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-slate-900 dark:text-white mb-1">
|
||||
{stats.totalUsers}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Registered accounts</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-600 dark:text-gray-400">Registrations</h3>
|
||||
<div className="h-10 w-10 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<span className="text-xl">📝</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-slate-900 dark:text-white mb-1">
|
||||
{stats.totalRegistrations}
|
||||
</div>
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<span>📊</span> 0 this month
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-600 dark:text-gray-400">Total Revenue</h3>
|
||||
<div className="h-10 w-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<span className="text-xl">💰</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-slate-900 dark:text-white mb-1">
|
||||
${stats.revenue.toFixed(0)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">From paid webinars</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Recent Registrations</h2>
|
||||
<a href="/admin/registrations" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||
View All <span>→</span>
|
||||
</a>
|
||||
</div>
|
||||
{recentRegistrations.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No registrations yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentRegistrations.map((reg) => (
|
||||
<div
|
||||
key={reg.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-slate-800/50"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-white text-sm">
|
||||
{reg.user?.firstName} {reg.user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{reg.webinar?.title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(reg.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Upcoming Webinars</h2>
|
||||
<a href="/admin/webinars" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||
Manage <span>→</span>
|
||||
</a>
|
||||
</div>
|
||||
{upcomingWebinars.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">No upcoming webinars</p>
|
||||
<a href="/admin/webinars" className="btn-primary btn-sm">
|
||||
Create Webinar
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingWebinars.map((webinar) => (
|
||||
<div
|
||||
key={webinar.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-slate-800/50"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-white text-sm">
|
||||
{webinar.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{webinar.speaker}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(webinar.startAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
app/admin/registrations/page.tsx
Normal file
196
app/admin/registrations/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface Registration {
|
||||
id: string;
|
||||
status: string;
|
||||
paymentStatus: string;
|
||||
createdAt: string;
|
||||
user: {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
webinar: {
|
||||
title: string;
|
||||
dateTime: string;
|
||||
price: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminRegistrationsPage() {
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>("ALL");
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegistrations();
|
||||
}, []);
|
||||
|
||||
const fetchRegistrations = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/registrations");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRegistrations(data.registrations || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch registrations:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRegistrations =
|
||||
filter === "ALL"
|
||||
? registrations
|
||||
: registrations.filter((reg) => reg.status === filter);
|
||||
|
||||
const stats = {
|
||||
total: registrations.length,
|
||||
confirmed: registrations.filter((r) => r.status === "CONFIRMED").length,
|
||||
pending: registrations.filter((r) => r.status === "PENDING").length,
|
||||
cancelled: registrations.filter((r) => r.status === "CANCELLED").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Registrations Management
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
View and manage webinar registrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="card p-6">
|
||||
<div className="text-3xl font-bold text-primary mb-2">{stats.total}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Registrations</div>
|
||||
</div>
|
||||
<div className="card p-6">
|
||||
<div className="text-3xl font-bold text-green-600 mb-2">{stats.confirmed}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Confirmed</div>
|
||||
</div>
|
||||
<div className="card p-6">
|
||||
<div className="text-3xl font-bold text-yellow-600 mb-2">{stats.pending}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Pending</div>
|
||||
</div>
|
||||
<div className="card p-6">
|
||||
<div className="text-3xl font-bold text-red-600 mb-2">{stats.cancelled}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Cancelled</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="flex gap-2">
|
||||
{["ALL", "CONFIRMED", "PENDING", "CANCELLED"].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition-all ${
|
||||
filter === status
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block w-8 h-8 border-4 border-primary border-r-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading registrations...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
Webinar
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
Payment
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{filteredRegistrations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No registrations found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRegistrations.map((reg) => (
|
||||
<tr key={reg.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{reg.user.firstName} {reg.user.lastName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{reg.user.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{reg.webinar.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(reg.webinar.dateTime).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
reg.status === "CONFIRMED"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: reg.status === "PENDING"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{reg.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
reg.paymentStatus === "COMPLETED"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: reg.paymentStatus === "FREE"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{reg.paymentStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
|
||||
{new Date(reg.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
936
app/admin/setup/page.tsx
Normal file
936
app/admin/setup/page.tsx
Normal file
@@ -0,0 +1,936 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface SocialMedia {
|
||||
url: string;
|
||||
display: boolean;
|
||||
}
|
||||
|
||||
interface Socials {
|
||||
facebook?: SocialMedia;
|
||||
instagram?: SocialMedia;
|
||||
twitter?: SocialMedia;
|
||||
linkedin?: SocialMedia;
|
||||
youtube?: SocialMedia;
|
||||
}
|
||||
|
||||
interface OAuthProvider {
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
interface OAuthConfig {
|
||||
google?: OAuthProvider;
|
||||
github?: OAuthProvider;
|
||||
facebook?: OAuthProvider;
|
||||
discord?: OAuthProvider;
|
||||
}
|
||||
|
||||
export default function AdminSetupPage() {
|
||||
const [config, setConfig] = useState({
|
||||
googleAuth: {
|
||||
enabled: false,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
oauth: {
|
||||
google: { enabled: false, clientId: "", clientSecret: "" },
|
||||
github: { enabled: false, clientId: "", clientSecret: "" },
|
||||
facebook: { enabled: false, clientId: "", clientSecret: "" },
|
||||
discord: { enabled: false, clientId: "", clientSecret: "" },
|
||||
} as OAuthConfig,
|
||||
googleCalendar: {
|
||||
enabled: false,
|
||||
serviceAccountEmail: "",
|
||||
serviceAccountKey: "",
|
||||
calendarId: "",
|
||||
},
|
||||
socials: {} as Socials,
|
||||
email: {
|
||||
smtp: {
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 587,
|
||||
username: "",
|
||||
password: "",
|
||||
from: "",
|
||||
},
|
||||
},
|
||||
pagination: {
|
||||
itemsPerPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/setup");
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
// Ensure oauth object exists with all providers
|
||||
const fetchedConfig = data.data;
|
||||
if (!fetchedConfig.oauth) {
|
||||
fetchedConfig.oauth = {
|
||||
google: { enabled: false, clientId: "", clientSecret: "" },
|
||||
github: { enabled: false, clientId: "", clientSecret: "" },
|
||||
facebook: { enabled: false, clientId: "", clientSecret: "" },
|
||||
discord: { enabled: false, clientId: "", clientSecret: "" },
|
||||
};
|
||||
}
|
||||
setConfig(fetchedConfig);
|
||||
} else {
|
||||
setMessage(`❌ Failed to load config: ${data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching config:", error);
|
||||
setMessage(`❌ Error loading configuration: ${error}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
setMessage("✅ Settings saved successfully!");
|
||||
} else {
|
||||
setMessage(`❌ ${data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage("❌ Failed to save settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSocial = (platform: keyof Socials, field: "url" | "display", value: string | boolean) => {
|
||||
setConfig({
|
||||
...config,
|
||||
socials: {
|
||||
...config.socials,
|
||||
[platform]: {
|
||||
...config.socials[platform],
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-16">
|
||||
<div className="text-center">Loading configuration...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-16">
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 text-primary text-xs font-semibold mb-4">
|
||||
🛠️ Admin Settings
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-white">
|
||||
System Setup
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Configure authentication, social media, and email settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Google OAuth Configuration */}
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-2xl">🔐</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Google OAuth</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Enable Google sign-in for users</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="googleEnabled"
|
||||
checked={config.googleAuth.enabled}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
googleAuth: { ...config.googleAuth, enabled: e.target.checked },
|
||||
})
|
||||
}
|
||||
className="w-5 h-5 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="googleEnabled"
|
||||
className="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Enable Google Sign-In
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Google Client ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="Your Google OAuth Client ID"
|
||||
value={config.googleAuth.clientId}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
googleAuth: { ...config.googleAuth, clientId: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Google Client Secret
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field"
|
||||
placeholder="Your Google OAuth Client Secret"
|
||||
value={config.googleAuth.clientSecret}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
googleAuth: { ...config.googleAuth, clientSecret: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BetterAuth OAuth Providers */}
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-6 mt-6">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 text-xs font-semibold mb-4">
|
||||
🔑 OAuth Providers (BetterAuth)
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure social OAuth providers for user authentication. Get credentials from each provider's developer console.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Google OAuth */}
|
||||
<div className="card p-6 border border-blue-200/40 dark:border-blue-900/30 bg-blue-50/30 dark:bg-blue-950/20">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">🔍</span>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Google</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="googleOAuthEnabled"
|
||||
checked={config.oauth.google?.enabled || false}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
google: { ...(config.oauth.google || {}), enabled: e.target.checked } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="googleOAuthEnabled"
|
||||
className="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Enable Google OAuth
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field text-sm"
|
||||
placeholder="Client ID"
|
||||
value={config.oauth.google?.clientId || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
google: { ...(config.oauth.google || {}), clientId: e.target.value } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field text-sm"
|
||||
placeholder="Client Secret"
|
||||
value={config.oauth.google?.clientSecret || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
google: { ...(config.oauth.google || {}), clientSecret: e.target.value } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub OAuth */}
|
||||
<div className="card p-6 border border-gray-300/40 dark:border-gray-700/30 bg-gray-50/30 dark:bg-gray-950/20">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">🐙</span>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">GitHub</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="githubOAuthEnabled"
|
||||
checked={config.oauth.github?.enabled || false}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
github: { ...(config.oauth.github || {}), enabled: e.target.checked } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="githubOAuthEnabled"
|
||||
className="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Enable GitHub OAuth
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field text-sm"
|
||||
placeholder="Client ID"
|
||||
value={config.oauth.github?.clientId || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
github: { ...(config.oauth.github || {}), clientId: e.target.value } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field text-sm"
|
||||
placeholder="Client Secret"
|
||||
value={config.oauth.github?.clientSecret || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
github: { ...(config.oauth.github || {}), clientSecret: e.target.value } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Facebook OAuth */}
|
||||
<div className="card p-6 border border-blue-600/40 dark:border-blue-900/30 bg-blue-50/30 dark:bg-blue-950/20">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">👍</span>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Facebook</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="facebookOAuthEnabled"
|
||||
checked={config.oauth.facebook?.enabled || false}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
facebook: { ...(config.oauth.facebook || {}), enabled: e.target.checked } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="facebookOAuthEnabled"
|
||||
className="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Enable Facebook OAuth
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field text-sm"
|
||||
placeholder="App ID"
|
||||
value={config.oauth.facebook?.clientId || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
facebook: { ...(config.oauth.facebook || {}), clientId: e.target.value } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field text-sm"
|
||||
placeholder="App Secret"
|
||||
value={config.oauth.facebook?.clientSecret || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
facebook: { ...(config.oauth.facebook || {}), clientSecret: e.target.value } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discord OAuth */}
|
||||
<div className="card p-6 border border-indigo-500/40 dark:border-indigo-900/30 bg-indigo-50/30 dark:bg-indigo-950/20">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">💬</span>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Discord</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="discordOAuthEnabled"
|
||||
checked={config.oauth.discord?.enabled || false}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
discord: { ...(config.oauth.discord || {}), enabled: e.target.checked } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="discordOAuthEnabled"
|
||||
className="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Enable Discord OAuth
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field text-sm"
|
||||
placeholder="Client ID"
|
||||
value={config.oauth.discord?.clientId || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
discord: { ...(config.oauth.discord || {}), clientId: e.target.value } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field text-sm"
|
||||
placeholder="Client Secret"
|
||||
value={config.oauth.discord?.clientSecret || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
discord: { ...(config.oauth.discord || {}), clientSecret: e.target.value } as OAuthProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Calendar Configuration */}
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-2xl">📅</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Google Calendar</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Enable calendar invites for webinar registrations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="calendarEnabled"
|
||||
checked={config.googleCalendar.enabled}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
googleCalendar: { ...config.googleCalendar, enabled: e.target.checked },
|
||||
})
|
||||
}
|
||||
className="w-5 h-5 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="calendarEnabled"
|
||||
className="text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Enable Calendar Invites
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Service Account Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input-field"
|
||||
placeholder="service-account@project.iam.gserviceaccount.com"
|
||||
value={config.googleCalendar.serviceAccountEmail}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
googleCalendar: { ...config.googleCalendar, serviceAccountEmail: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Create a service account in Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Service Account Private Key (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
className="input-field font-mono text-xs"
|
||||
rows={4}
|
||||
placeholder='{"type": "service_account", "project_id": "...", "private_key": "...", ...}'
|
||||
value={config.googleCalendar.serviceAccountKey}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
googleCalendar: { ...config.googleCalendar, serviceAccountKey: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Paste the entire JSON key file content
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Calendar ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="primary or your-calendar@group.calendar.google.com"
|
||||
value={config.googleCalendar.calendarId}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
googleCalendar: { ...config.googleCalendar, calendarId: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Use "primary" for the service account's calendar or specify a shared calendar ID
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Media Configuration */}
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-2xl">🌐</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Social Media</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Configure social media links and visibility</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* Facebook */}
|
||||
<div className="border-b border-slate-200 dark:border-slate-700 pb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xl">📘</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Facebook</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
className="input-field"
|
||||
placeholder="https://facebook.com/yourpage"
|
||||
value={config.socials.facebook?.url || ""}
|
||||
onChange={(e) => updateSocial("facebook", "url", e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="facebookDisplay"
|
||||
checked={config.socials.facebook?.display || false}
|
||||
onChange={(e) => updateSocial("facebook", "display", e.target.checked)}
|
||||
className="w-5 h-5 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="facebookDisplay" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Display on landing page
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instagram */}
|
||||
<div className="border-b border-slate-200 dark:border-slate-700 pb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xl">📷</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Instagram</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
className="input-field"
|
||||
placeholder="https://instagram.com/yourprofile"
|
||||
value={config.socials.instagram?.url || ""}
|
||||
onChange={(e) => updateSocial("instagram", "url", e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="instagramDisplay"
|
||||
checked={config.socials.instagram?.display || false}
|
||||
onChange={(e) => updateSocial("instagram", "display", e.target.checked)}
|
||||
className="w-5 h-5 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="instagramDisplay" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Display on landing page
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Twitter */}
|
||||
<div className="border-b border-slate-200 dark:border-slate-700 pb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xl">🐦</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Twitter</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
className="input-field"
|
||||
placeholder="https://twitter.com/yourhandle"
|
||||
value={config.socials.twitter?.url || ""}
|
||||
onChange={(e) => updateSocial("twitter", "url", e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="twitterDisplay"
|
||||
checked={config.socials.twitter?.display || false}
|
||||
onChange={(e) => updateSocial("twitter", "display", e.target.checked)}
|
||||
className="w-5 h-5 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="twitterDisplay" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Display on landing page
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LinkedIn */}
|
||||
<div className="border-b border-slate-200 dark:border-slate-700 pb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xl">💼</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">LinkedIn</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
className="input-field"
|
||||
placeholder="https://linkedin.com/company/yourcompany"
|
||||
value={config.socials.linkedin?.url || ""}
|
||||
onChange={(e) => updateSocial("linkedin", "url", e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="linkedinDisplay"
|
||||
checked={config.socials.linkedin?.display || false}
|
||||
onChange={(e) => updateSocial("linkedin", "display", e.target.checked)}
|
||||
className="w-5 h-5 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="linkedinDisplay" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Display on landing page
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YouTube */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xl">📺</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">YouTube</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
className="input-field"
|
||||
placeholder="https://youtube.com/channel/yourchannel"
|
||||
value={config.socials.youtube?.url || ""}
|
||||
onChange={(e) => updateSocial("youtube", "url", e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="youtubeDisplay"
|
||||
checked={config.socials.youtube?.display || false}
|
||||
onChange={(e) => updateSocial("youtube", "display", e.target.checked)}
|
||||
className="w-5 h-5 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="youtubeDisplay" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Display on landing page
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SMTP Email Configuration */}
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-2xl">📧</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Email (SMTP)</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Configure email server for notifications and activation</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="smtpEnabled"
|
||||
checked={config.email.smtp.enabled}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
email: {
|
||||
...config.email,
|
||||
smtp: { ...config.email.smtp, enabled: e.target.checked },
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-5 h-5 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="smtpEnabled" className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Enable SMTP Email
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
SMTP Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="smtp.gmail.com"
|
||||
value={config.email.smtp.host}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
email: {
|
||||
...config.email,
|
||||
smtp: { ...config.email.smtp, host: e.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
placeholder="587"
|
||||
value={config.email.smtp.port}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
email: {
|
||||
...config.email,
|
||||
smtp: { ...config.email.smtp, port: parseInt(e.target.value) || 587 },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="your-email@gmail.com"
|
||||
value={config.email.smtp.username}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
email: {
|
||||
...config.email,
|
||||
smtp: { ...config.email.smtp, username: e.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field"
|
||||
placeholder="Your SMTP password or app password"
|
||||
value={config.email.smtp.password}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
email: {
|
||||
...config.email,
|
||||
smtp: { ...config.email.smtp, password: e.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
From Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input-field"
|
||||
placeholder="noreply@yourdomain.com"
|
||||
value={config.email.smtp.from}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
email: {
|
||||
...config.email,
|
||||
smtp: { ...config.email.smtp, from: e.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Configuration */}
|
||||
<div className="card p-6 border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-2xl">📄</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Pagination</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Configure pagination settings for lists</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Items Per Page
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="100"
|
||||
className="input-field"
|
||||
placeholder="10"
|
||||
value={config.pagination.itemsPerPage}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
pagination: {
|
||||
itemsPerPage: Math.max(5, Math.min(100, parseInt(e.target.value) || 10)),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Used for users and webinars list (min: 5, max: 100)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={handleSave} disabled={saving} className="btn-primary">
|
||||
{saving ? "⏳ Saving..." : "💾 Save Settings"}
|
||||
</button>
|
||||
{message && (
|
||||
<span
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold ${
|
||||
message.includes("✅")
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
133
app/admin/users/page-old.tsx
Normal file
133
app/admin/users/page-old.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getSession } from "../../../lib/auth/session";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/users");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
`${user.firstName} ${user.lastName}`.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Users Management
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by name or email..."
|
||||
className="input-field"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block w-8 h-8 border-4 border-primary border-r-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading users...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">
|
||||
Joined
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{user.firstName} {user.lastName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
user.role === "ADMIN"
|
||||
? "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
345
app/admin/users/page.tsx
Normal file
345
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface WebinarRegistration {
|
||||
id: string;
|
||||
status: string;
|
||||
webinar: {
|
||||
id: string;
|
||||
title: string;
|
||||
startAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
emailVerified: boolean;
|
||||
createdAt: string;
|
||||
gender?: string;
|
||||
dob?: string;
|
||||
address?: string;
|
||||
image?: string;
|
||||
_count: {
|
||||
webinarRegistrations: number;
|
||||
};
|
||||
registeredWebinars: WebinarRegistration[];
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
pages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers(1);
|
||||
}, [searchQuery]);
|
||||
|
||||
const fetchUsers = async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page) });
|
||||
if (searchQuery) params.append("search", searchQuery);
|
||||
|
||||
const response = await fetch(`/api/admin/users?${params}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
setUsers(data.users);
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
setMessage("❌ Failed to load users");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockUnblock = async (userId: string, currentlyActive: boolean) => {
|
||||
setUpdatingUserId(userId);
|
||||
try {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
isActive: !currentlyActive,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
setMessage(`✅ ${data.message}`);
|
||||
fetchUsers(pagination.page);
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
} else {
|
||||
setMessage(`❌ ${data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage("❌ Failed to update user");
|
||||
} finally {
|
||||
setUpdatingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: "USER" | "ADMIN") => {
|
||||
setUpdatingUserId(userId);
|
||||
try {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
role: newRole,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
setMessage(`✅ ${data.message}`);
|
||||
fetchUsers(pagination.page);
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
} else {
|
||||
setMessage(`❌ ${data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage("❌ Failed to update user role");
|
||||
} finally {
|
||||
setUpdatingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 text-primary text-xs font-semibold mb-4">
|
||||
👥 User Management
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-white">
|
||||
Users
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Manage user accounts, roles, and permissions ({pagination.total} total)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-4 px-4 py-3 rounded-lg font-semibold ${
|
||||
message.includes("✅")
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-6 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="🔍 Search by name or email..."
|
||||
className="input-field"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block w-8 h-8 border-4 border-primary border-r-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading users...</p>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="card p-6 border border-slate-200/60 dark:border-slate-700/60 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{user.image ? (
|
||||
<img
|
||||
src={user.image}
|
||||
alt={user.firstName}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-white font-bold">
|
||||
{user.firstName[0]}{user.lastName[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-gray-900 dark:text-white">
|
||||
{user.firstName} {user.lastName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{!user.isActive && (
|
||||
<span className="px-2 py-1 bg-red-500/10 text-red-600 dark:text-red-400 text-xs font-semibold rounded">
|
||||
🚫 BLOCKED
|
||||
</span>
|
||||
)}
|
||||
{user.emailVerified && (
|
||||
<span className="px-2 py-1 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-xs font-semibold rounded">
|
||||
✅ Verified
|
||||
</span>
|
||||
)}
|
||||
{user.role === "ADMIN" && (
|
||||
<span className="px-2 py-1 bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-semibold rounded">
|
||||
👑 Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Joined: {new Date(user.createdAt).toLocaleDateString()}</p>
|
||||
<p>Registrations: {user._count.webinarRegistrations}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registered Webinars */}
|
||||
{user.registeredWebinars.length > 0 && (
|
||||
<div className="mb-4 pb-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
📚 Recent Webinars ({user.registeredWebinars.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{user.registeredWebinars.map((reg) => (
|
||||
<div
|
||||
key={reg.id}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-2"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-primary"></span>
|
||||
<Link
|
||||
href={`/admin/webinars#${reg.webinar.id}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
{reg.webinar.title}
|
||||
</Link>
|
||||
<span className="text-xs">
|
||||
({new Date(reg.webinar.startAt).toLocaleDateString()})
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-semibold rounded ${
|
||||
reg.status === "CONFIRMED"
|
||||
? "bg-emerald-500/10 text-emerald-600"
|
||||
: reg.status === "PAID"
|
||||
? "bg-blue-500/10 text-blue-600"
|
||||
: "bg-yellow-500/10 text-yellow-600"
|
||||
}`}
|
||||
>
|
||||
{reg.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value as "USER" | "ADMIN")}
|
||||
disabled={updatingUserId === user.id}
|
||||
className="px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-sm font-semibold text-gray-900 dark:text-white disabled:opacity-50"
|
||||
>
|
||||
<option value="USER">👤 User</option>
|
||||
<option value="ADMIN">👑 Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleBlockUnblock(user.id, user.isActive)}
|
||||
disabled={updatingUserId === user.id}
|
||||
className={`px-4 py-2 rounded-lg font-semibold text-sm transition-colors disabled:opacity-50 ${
|
||||
user.isActive
|
||||
? "bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20"
|
||||
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20"
|
||||
}`}
|
||||
>
|
||||
{updatingUserId === user.id
|
||||
? "⏳"
|
||||
: user.isActive
|
||||
? "🚫 Block"
|
||||
: "✅ Unblock"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchUsers(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-gray-900 dark:text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: pagination.pages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => fetchUsers(page)}
|
||||
className={`w-10 h-10 rounded-lg font-semibold transition-colors ${
|
||||
pagination.page === page
|
||||
? "bg-primary text-white"
|
||||
: "bg-slate-100 dark:bg-slate-800 text-gray-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchUsers(pagination.page + 1)}
|
||||
disabled={!pagination.hasMore}
|
||||
className="px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-gray-900 dark:text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
270
app/admin/webinars/page.tsx
Normal file
270
app/admin/webinars/page.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import WebinarModal from "../../../components/admin/WebinarModal";
|
||||
|
||||
interface Webinar {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
speaker: string;
|
||||
startAt: string;
|
||||
duration: number;
|
||||
priceCents: number;
|
||||
visibility: string;
|
||||
capacity: number;
|
||||
isActive: boolean;
|
||||
_count?: { registrations: number };
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export default function AdminWebinarsPage() {
|
||||
const [webinars, setWebinars] = useState<Webinar[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
pages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingWebinar, setEditingWebinar] = useState<Webinar | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWebinars(1);
|
||||
}, []);
|
||||
|
||||
const fetchWebinars = async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// API uses 0-indexed pages (page 0 is first page)
|
||||
const apiPage = page - 1;
|
||||
const response = await fetch(`/api/webinars?page=${apiPage}&limit=100`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setWebinars(data.webinars || []);
|
||||
setPagination({
|
||||
page,
|
||||
pageSize: data.limit || 10,
|
||||
total: data.total || 0,
|
||||
pages: Math.ceil((data.total || 0) / (data.limit || 10)),
|
||||
hasMore: page < Math.ceil((data.total || 0) / (data.limit || 10)),
|
||||
});
|
||||
} else {
|
||||
console.error("Failed to fetch webinars:", response.status);
|
||||
setWebinars([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch webinars:", error);
|
||||
setWebinars([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWebinar = () => {
|
||||
setEditingWebinar(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditWebinar = (webinar: Webinar) => {
|
||||
setEditingWebinar(webinar);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteWebinar = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this webinar?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/webinars/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchWebinars(pagination.page);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete webinar:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 text-primary text-xs font-semibold mb-4">
|
||||
🎬 Webinars
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
Webinars Management
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Create and manage your webinars ({pagination.total} total)
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleCreateWebinar} className="btn-primary">
|
||||
➕ Create Webinar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block w-8 h-8 border-4 border-primary border-r-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading webinars...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
{webinars.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<div className="text-6xl mb-4">📹</div>
|
||||
<p className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No webinars yet
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Create your first webinar to get started
|
||||
</p>
|
||||
<button onClick={handleCreateWebinar} className="btn-primary">
|
||||
Create Webinar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
webinars.map((webinar) => (
|
||||
<div key={webinar.id} className="card p-6 hover:shadow-lg transition-all">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{webinar.title}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
webinar.isActive
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{webinar.isActive ? "✅ Active" : "⏸️ Inactive"}
|
||||
</span>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
webinar.visibility === "PUBLIC"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{webinar.visibility === "PUBLIC" ? "🔓 Public" : "🔒 Private"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{webinar.description}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Speaker:</span>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{webinar.speaker}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Date:</span>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{new Date(webinar.startAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Price:</span>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{webinar.priceCents === 0 ? "FREE" : `$${(webinar.priceCents / 100).toFixed(2)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Capacity:</span>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{webinar.capacity} seats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => handleEditWebinar(webinar)}
|
||||
className="px-3 py-2 text-sm font-semibold rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteWebinar(webinar.id)}
|
||||
className="px-3 py-2 text-sm font-semibold rounded-lg bg-red-500/10 text-red-600 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchWebinars(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-gray-900 dark:text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: pagination.pages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => fetchWebinars(page)}
|
||||
className={`w-10 h-10 rounded-lg font-semibold transition-colors ${
|
||||
pagination.page === page
|
||||
? "bg-primary text-white"
|
||||
: "bg-slate-100 dark:bg-slate-800 text-gray-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchWebinars(pagination.page + 1)}
|
||||
disabled={!pagination.hasMore}
|
||||
className="px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-gray-900 dark:text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<WebinarModal
|
||||
webinar={editingWebinar}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingWebinar(null);
|
||||
}}
|
||||
onSave={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingWebinar(null);
|
||||
fetchWebinars(pagination.page);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user