Initial commit
This commit is contained in:
271
app/about/page.tsx
Normal file
271
app/about/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
export default function AboutPage() {
|
||||
const values = [
|
||||
{
|
||||
icon: "🎯",
|
||||
title: "Mission-Driven",
|
||||
description: "Making estate planning accessible and understandable for everyone through comprehensive education.",
|
||||
},
|
||||
{
|
||||
icon: "🏆",
|
||||
title: "Excellence",
|
||||
description: "Delivering high-quality webinars and resources from industry-leading experts.",
|
||||
},
|
||||
{
|
||||
icon: "🤝",
|
||||
title: "Community",
|
||||
description: "Building a supportive community where people can learn and share their experiences.",
|
||||
},
|
||||
{
|
||||
icon: "💡",
|
||||
title: "Innovation",
|
||||
description: "Continuously improving our platform and educational approach based on feedback.",
|
||||
},
|
||||
];
|
||||
|
||||
const team = [
|
||||
{
|
||||
name: "Sarah Johnson",
|
||||
role: "Founder & CEO",
|
||||
bio: "20+ years of experience in estate planning and financial advisory.",
|
||||
icon: "👩💼",
|
||||
},
|
||||
{
|
||||
name: "Michael Chen",
|
||||
role: "Chief Educational Officer",
|
||||
bio: "Expert educator with a passion for making complex topics simple.",
|
||||
icon: "👨🏫",
|
||||
},
|
||||
{
|
||||
name: "Emily Rodriguez",
|
||||
role: "Community Manager",
|
||||
bio: "Dedicated to building and nurturing our thriving community.",
|
||||
icon: "👩💻",
|
||||
},
|
||||
{
|
||||
name: "David Thompson",
|
||||
role: "Lead Consultant",
|
||||
bio: "Certified financial planner with specialized estate planning expertise.",
|
||||
icon: "👨⚖️",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="relative overflow-hidden">
|
||||
{/* Background gradients */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900" />
|
||||
<div className="absolute -top-24 right-0 h-64 w-64 rounded-full bg-primary/15 blur-3xl" />
|
||||
<div className="absolute -bottom-24 left-0 h-64 w-64 rounded-full bg-secondary/15 blur-3xl" />
|
||||
<div className="absolute top-1/2 right-1/4 h-48 w-48 rounded-full bg-cyan-400/10 blur-3xl" />
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-6 py-16 lg:py-20">
|
||||
{/* Header Section */}
|
||||
<div className="text-center mb-16">
|
||||
<p className="inline-flex items-center gap-2 text-sm font-semibold tracking-wide uppercase text-primary/80 bg-primary/10 px-3 py-1 rounded-full">
|
||||
Learn our story
|
||||
</p>
|
||||
<h1 className="mt-4 text-4xl md:text-5xl font-bold text-gray-900 dark:text-white">
|
||||
About Estate Planning Hub
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Empowering individuals to make informed decisions about their financial future and legacy through expert-led education.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission & Vision Section */}
|
||||
<div className="grid lg:grid-cols-2 gap-8 mb-16">
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 backdrop-blur-md p-8 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-primary/15 text-primary flex items-center justify-center text-2xl">
|
||||
🎯
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Our Mission</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
We are dedicated to making estate planning accessible and understandable for everyone. Through our comprehensive webinars and educational resources, we empower individuals to make informed decisions about their financial future and legacy. We believe that proper estate planning shouldn't be intimidating or exclusive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 backdrop-blur-md p-8 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-secondary/15 text-secondary flex items-center justify-center text-2xl">
|
||||
✨
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Our Vision</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
We envision a world where everyone has access to the knowledge and tools needed to plan their estate with confidence. Our platform brings together expert advisors, comprehensive educational content, and a supportive community to guide you through every step of the estate planning process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What We Offer Section */}
|
||||
<div className="mb-16">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
What We Offer
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Comprehensive resources and expert guidance to support your estate planning journey
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="rounded-xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-4xl mb-3">📚</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Expert-Led Webinars</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Learn from experienced estate planning professionals and financial advisors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-4xl mb-3">💡</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Comprehensive Resources</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Access guides, templates, checklists, and educational materials.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-4xl mb-3">🤝</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Community Support</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Connect with others on their estate planning journey and share experiences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-4xl mb-3">🎁</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Legacy Planning Tools</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Tools to help you plan and organize your legacy effectively.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Values Section */}
|
||||
<div className="mb-16">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Our Core Values
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
The principles that guide everything we do
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{values.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-4xl flex-shrink-0">{value.icon}</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{value.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{value.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Section */}
|
||||
<div className="mb-16">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Meet Our Team
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Experienced professionals dedicated to your estate planning success
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{team.map((member, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 p-6 text-center hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="text-5xl mb-4 flex justify-center">{member.icon}</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{member.name}
|
||||
</h3>
|
||||
<p className="text-sm font-medium text-primary dark:text-primary/80 mb-2">
|
||||
{member.role}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{member.bio}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why Choose Us Section */}
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-gradient-to-br from-primary/10 via-transparent to-secondary/10 dark:from-primary/20 dark:via-transparent dark:to-secondary/20 p-10 md:p-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-8 text-center">
|
||||
Why Choose Estate Planning Hub?
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 flex items-start justify-center">
|
||||
<div className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/20 text-primary">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Trusted Expertise
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Learn from seasoned professionals with decades of combined experience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 flex items-start justify-center">
|
||||
<div className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/20 text-primary">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Accessible Learning
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Complex topics explained in simple, easy-to-understand language.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 flex items-start justify-center">
|
||||
<div className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/20 text-primary">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Practical Tools
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Real-world templates and resources you can use immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
52
app/account/page.tsx
Normal file
52
app/account/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getSession } from "../../lib/auth/session";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function AccountPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">👤 Account</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-3">Please sign in to view your account.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.forcePasswordReset) {
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">⚠️ Action required</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-3">
|
||||
You must reset your password before continuing.
|
||||
</p>
|
||||
<Link className="inline-block mt-6 btn-primary" href="/account/reset-password">
|
||||
🔐 Reset password
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-16">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">👤 Account</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-3">
|
||||
Signed in as <span className="font-semibold text-primary">{session.email}</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-3 flex flex-col w-fit">
|
||||
<Link className="btn-secondary" href="/account/settings">
|
||||
⚙️ Settings
|
||||
</Link>
|
||||
<Link className="btn-secondary" href="/account/webinars">
|
||||
📚 My Webinars
|
||||
</Link>
|
||||
{session.role === "ADMIN" && (
|
||||
<Link className="btn-primary" href="/admin">
|
||||
🔧 Admin Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
32
app/account/settings/page.tsx
Normal file
32
app/account/settings/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getSession } from "../../../lib/auth/session";
|
||||
import SettingsClient from "./settings-client";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-16">
|
||||
<div className="card text-center py-12">
|
||||
<div className="text-6xl mb-4">🔒</div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-slate-900 dark:text-white">Authentication Required</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please sign in to access settings.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl 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">
|
||||
⚙️ Settings
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 dark:text-white">
|
||||
Account Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Manage your profile, security, and preferences</p>
|
||||
</div>
|
||||
<SettingsClient />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
360
app/account/settings/settings-client.tsx
Normal file
360
app/account/settings/settings-client.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { getPasswordRequirements, PasswordRequirement } from "../../../lib/auth/validation";
|
||||
|
||||
export default function SettingsClient() {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [passwordRequirements, setPasswordRequirements] = useState<PasswordRequirement[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getMinDate = (): string => {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() - 100);
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const getMaxDate = (): string => {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() - 18);
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const [profile, setProfile] = useState({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
gender: "",
|
||||
dob: "",
|
||||
address: "",
|
||||
avatarUrl: "",
|
||||
email: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/account/profile")
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.ok && setProfile(d.profile))
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPasswordRequirements(getPasswordRequirements(newPassword));
|
||||
}, [newPassword]);
|
||||
|
||||
async function submit() {
|
||||
setMsg(null);
|
||||
|
||||
// Client-side validation
|
||||
if (!currentPassword.trim()) {
|
||||
setMsg("Current password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate new password meets all requirements
|
||||
const unmetRequirements = passwordRequirements.filter(req => !req.met);
|
||||
if (unmetRequirements.length > 0) {
|
||||
const messages = unmetRequirements.map(req => req.name).join(", ");
|
||||
setMsg(`Password requirements not met: ${messages}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setMsg("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword, confirmPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
setMsg(data.ok ? "✅ Password updated." : data.message);
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
setMsg(null);
|
||||
const res = await fetch("/api/account/profile", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(profile),
|
||||
});
|
||||
const data = await res.json();
|
||||
setMsg(data.ok ? "✅ Profile updated." : data.message);
|
||||
|
||||
// Reload profile data to show updated avatar
|
||||
if (data.ok) {
|
||||
const profileRes = await fetch("/api/account/profile");
|
||||
const profileData = await profileRes.json();
|
||||
if (profileData.ok) {
|
||||
setProfile(profileData.profile);
|
||||
}
|
||||
// Trigger navbar refresh by dispatching custom event
|
||||
window.dispatchEvent(new Event('profile-updated'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setMsg("Please select an image file");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setMsg("Image size must be less than 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setMsg(null);
|
||||
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setProfile({ ...profile, avatarUrl: base64 });
|
||||
setUploading(false);
|
||||
setMsg("✅ Image loaded. Click 'Save Profile' to update.");
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setUploading(false);
|
||||
setMsg("Failed to read image file");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="card border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-secondary text-white flex items-center justify-center shadow-md">
|
||||
👤
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Profile Information</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Update your personal details</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Profile Photo Section */}
|
||||
<div className="flex items-center gap-6 p-5 rounded-2xl bg-gradient-to-r from-primary/5 to-secondary/5 border border-primary/10">
|
||||
<div className="relative">
|
||||
{profile.avatarUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={profile.avatarUrl}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover ring-4 ring-white dark:ring-slate-700 shadow-xl"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gradient-to-r from-primary to-secondary text-white flex items-center justify-center text-3xl font-bold shadow-xl">
|
||||
{profile.firstName?.[0]?.toUpperCase() || "U"}
|
||||
</div>
|
||||
)}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1 text-slate-900 dark:text-white">Profile Photo</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
JPG, PNG or GIF. Max size 5MB.
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn-secondary text-sm"
|
||||
disabled={uploading}
|
||||
>
|
||||
📷 Choose Photo
|
||||
</button>
|
||||
{profile.avatarUrl && (
|
||||
<button
|
||||
onClick={() => setProfile({ ...profile, avatarUrl: "" })}
|
||||
className="px-4 py-2 rounded-xl text-sm text-danger hover:bg-danger/10 transition-all duration-200"
|
||||
>
|
||||
🗑️ Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">First Name *</label>
|
||||
<input
|
||||
className="input-field w-full"
|
||||
placeholder="First name"
|
||||
value={profile.firstName}
|
||||
onChange={(e) => setProfile({ ...profile, firstName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Last Name *</label>
|
||||
<input
|
||||
className="input-field w-full"
|
||||
placeholder="Last name"
|
||||
value={profile.lastName}
|
||||
onChange={(e) => setProfile({ ...profile, lastName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Email</label>
|
||||
<input
|
||||
className="input-field w-full bg-gray-100 dark:bg-slate-800 cursor-not-allowed"
|
||||
value={profile.email}
|
||||
disabled
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Gender</label>
|
||||
<select
|
||||
className="input-field w-full"
|
||||
value={profile.gender}
|
||||
onChange={(e) => setProfile({ ...profile, gender: e.target.value })}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="FEMALE">Female</option>
|
||||
<option value="MALE">Male</option>
|
||||
<option value="OTHER">Other</option>
|
||||
<option value="PREFER_NOT_TO_SAY">Prefer not to say</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Date of Birth</label>
|
||||
<input
|
||||
className="input-field w-full"
|
||||
type="date"
|
||||
value={profile.dob}
|
||||
onChange={(e) => setProfile({ ...profile, dob: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Address</label>
|
||||
<textarea
|
||||
className="input-field w-full"
|
||||
rows={3}
|
||||
placeholder="Enter your address"
|
||||
value={profile.address}
|
||||
onChange={(e) => setProfile({ ...profile, address: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={saveProfile} className="btn-primary">
|
||||
💾 Save Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-secondary text-white flex items-center justify-center shadow-md">
|
||||
🔐
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Change Password</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Update your password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Current Password *</label>
|
||||
<input
|
||||
className="input-field w-full"
|
||||
placeholder="Enter current password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">New Password *</label>
|
||||
<input
|
||||
className="input-field w-full"
|
||||
placeholder="Enter new password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
{newPassword && (
|
||||
<div className="mt-4 p-4 bg-gray-50 dark:bg-slate-900/50 rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Password must include:</p>
|
||||
<div className="space-y-2">
|
||||
{passwordRequirements.map((req, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span className={`flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-full text-xs font-bold ${req.met ? "bg-success/20 text-success" : "bg-danger/20 text-danger"}`}>
|
||||
{req.met ? "✓" : "✕"}
|
||||
</span>
|
||||
<span className={`text-sm ${req.met ? "text-success font-medium" : "text-danger"}`}>
|
||||
{req.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Confirm New Password *</label>
|
||||
<input
|
||||
className="input-field w-full"
|
||||
placeholder="Confirm new password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
{confirmPassword && newPassword !== confirmPassword && (
|
||||
<p className="text-xs text-danger mt-1">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={submit} className="btn-primary">
|
||||
🔐 Update Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.includes("✅") ? "bg-success/10 text-success" : "bg-danger/10 text-danger"}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
app/account/webinars/page.tsx
Normal file
201
app/account/webinars/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Webinar {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
speaker: string;
|
||||
startAt: string;
|
||||
duration: number;
|
||||
bannerUrl?: string;
|
||||
category: string;
|
||||
capacity: number;
|
||||
priceCents: number;
|
||||
}
|
||||
|
||||
interface Registration {
|
||||
id: string;
|
||||
userId: string;
|
||||
webinarId: string;
|
||||
registeredAt: string;
|
||||
webinar: Webinar;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function AccountWebinarsPage() {
|
||||
const router = useRouter();
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRegistrations() {
|
||||
try {
|
||||
const res = await fetch("/api/account/webinars");
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
throw new Error("Failed to fetch registrations");
|
||||
}
|
||||
const data = await res.json();
|
||||
setRegistrations(data.registrations || []);
|
||||
} catch (err) {
|
||||
console.error("Error fetching registrations:", err);
|
||||
setError("Failed to load your webinars");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRegistrations();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2 text-slate-900 dark:text-white">
|
||||
📚 My Webinars
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
View your registered webinars
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<p className="text-center text-gray-600 dark:text-gray-400 py-12">
|
||||
Loading your webinars...
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-7xl mx-auto px-6 py-16">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2 text-slate-900 dark:text-white">
|
||||
📚 My Webinars
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
View your registered webinars
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-600 dark:text-red-400 p-4 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrations.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
You haven't registered for any webinars yet.
|
||||
</p>
|
||||
<a href="/webinars" className="btn-primary btn-sm">
|
||||
🎓 Browse Webinars
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{registrations.map((registration) => {
|
||||
const webinar = registration.webinar;
|
||||
const startDate = new Date(webinar.startAt);
|
||||
const now = new Date();
|
||||
const isPast = startDate < now;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={registration.id}
|
||||
className="card group hover:shadow-lg dark:hover:shadow-lg/50 transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
{webinar.bannerUrl && (
|
||||
<div className="relative h-40 overflow-hidden bg-gradient-to-br from-primary/20 to-secondary/20">
|
||||
<img
|
||||
src={webinar.bannerUrl}
|
||||
alt={webinar.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
{isPast && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<span className="badge badge-secondary">Completed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white flex-1">
|
||||
{webinar.title}
|
||||
</h3>
|
||||
<span className="badge badge-primary text-xs ml-2">
|
||||
{webinar.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
{webinar.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span>🎤</span>
|
||||
<span className="font-medium">{webinar.speaker}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span>📅</span>
|
||||
<span>{formatDate(webinar.startAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span>⏱️</span>
|
||||
<span>{webinar.duration} minutes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400 text-xs">
|
||||
<span>✅</span>
|
||||
<span>
|
||||
Registered {formatDate(registration.registeredAt).split(",")[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`/webinars/${webinar.id}`}
|
||||
className="flex-1 btn-primary btn-sm"
|
||||
>
|
||||
📖 View Details
|
||||
</a>
|
||||
{!isPast && (
|
||||
<button className="flex-1 btn-secondary btn-sm">
|
||||
🔗 Join Now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
71
app/api/account/profile/route.ts
Normal file
71
app/api/account/profile/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
import { sanitizeText } from "../../../../lib/auth/validation";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
gender: z.string().optional().nullable(),
|
||||
dob: z.string().optional().nullable(),
|
||||
address: z.string().optional().nullable(),
|
||||
avatarUrl: z.string().optional().nullable(),
|
||||
email: z.string().optional(), // included in profile but not updatable
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.sub } });
|
||||
if (!user) return fail(new Error("Invalid user"));
|
||||
|
||||
return ok({
|
||||
profile: {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
gender: user.gender,
|
||||
dob: user.dob ? user.dob.toISOString().slice(0, 10) : "",
|
||||
address: user.address,
|
||||
avatarUrl: user.image,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = Body.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
console.error("Validation error:", parsed.error.errors);
|
||||
return fail(new Error("Invalid input: " + parsed.error.errors.map(e => e.message).join(", ")));
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
await prisma.user.update({
|
||||
where: { id: session.sub },
|
||||
data: {
|
||||
firstName: sanitizeText(data.firstName),
|
||||
lastName: sanitizeText(data.lastName),
|
||||
gender: data.gender ? sanitizeText(data.gender) : null,
|
||||
dob: data.dob ? new Date(data.dob) : null,
|
||||
address: data.address ? sanitizeText(data.address) : null,
|
||||
image: data.avatarUrl ? data.avatarUrl : null,
|
||||
},
|
||||
});
|
||||
|
||||
return ok({ message: "Profile updated" });
|
||||
}
|
||||
44
app/api/account/webinars/route.ts
Normal file
44
app/api/account/webinars/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const registrations = await prisma.webinarRegistration.findMany({
|
||||
where: { userId: session.sub },
|
||||
include: {
|
||||
webinar: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return ok({
|
||||
registrations: registrations.map((reg) => ({
|
||||
id: reg.id,
|
||||
userId: reg.userId,
|
||||
webinarId: reg.webinarId,
|
||||
registeredAt: reg.createdAt,
|
||||
webinar: {
|
||||
id: reg.webinar.id,
|
||||
title: reg.webinar.title,
|
||||
description: reg.webinar.description,
|
||||
startAt: reg.webinar.startAt,
|
||||
duration: reg.webinar.duration,
|
||||
speaker: reg.webinar.speaker,
|
||||
priceCents: reg.webinar.priceCents,
|
||||
category: reg.webinar.category,
|
||||
bannerUrl: reg.webinar.bannerUrl,
|
||||
capacity: reg.webinar.capacity,
|
||||
visibility: reg.webinar.visibility,
|
||||
isActive: reg.webinar.isActive,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
32
app/api/admin/contact-messages/route.ts
Normal file
32
app/api/admin/contact-messages/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = await getSession();
|
||||
if (!auth || auth.role !== "ADMIN") {
|
||||
return NextResponse.json({ ok: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Database not available" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await prisma.contactMessage.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, messages });
|
||||
} catch (error) {
|
||||
console.error("[ADMIN] Failed to fetch contact messages:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to fetch messages" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
175
app/api/admin/setup/route.ts
Normal file
175
app/api/admin/setup/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadSystemConfig, saveSystemConfig } from "@/lib/system-config";
|
||||
import { cookies } from "next/headers";
|
||||
import { verifySession } from "@/lib/auth/jwt";
|
||||
import { getCached, setCached, deleteCached, cacheKeys } from "@/lib/redis";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function validateSession() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("ep_session")?.value;
|
||||
if (!token) return null;
|
||||
try {
|
||||
const decoded = await verifySession(token);
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await validateSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
let cachedSetup = await getCached(cacheKeys.adminSetup);
|
||||
if (cachedSetup) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
data: cachedSetup,
|
||||
});
|
||||
}
|
||||
|
||||
const appSetup = await prisma.appSetup.findUnique({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const systemConfig = await loadSystemConfig();
|
||||
|
||||
const setupData = {
|
||||
googleAuth: {
|
||||
enabled: appSetup?.googleAuthEnabled || false,
|
||||
clientId: systemConfig.googleAuth?.clientId || "",
|
||||
clientSecret: systemConfig.googleAuth?.clientSecret || "",
|
||||
},
|
||||
oauth: {
|
||||
google: { enabled: false, clientId: "", clientSecret: "" },
|
||||
github: { enabled: false, clientId: "", clientSecret: "" },
|
||||
facebook: { enabled: false, clientId: "", clientSecret: "" },
|
||||
discord: { enabled: false, clientId: "", clientSecret: "" },
|
||||
},
|
||||
googleCalendar: {
|
||||
enabled: systemConfig.googleCalendar?.enabled || false,
|
||||
serviceAccountEmail: systemConfig.googleCalendar?.serviceAccountEmail || "",
|
||||
serviceAccountKey: systemConfig.googleCalendar?.serviceAccountKey || "",
|
||||
calendarId: systemConfig.googleCalendar?.calendarId || "",
|
||||
},
|
||||
socials: appSetup?.socials || {},
|
||||
email: {
|
||||
smtp: {
|
||||
enabled: systemConfig.email?.enabled || false,
|
||||
host: systemConfig.email?.smtp?.host || "",
|
||||
port: systemConfig.email?.smtp?.port || 587,
|
||||
username: systemConfig.email?.smtp?.user || "",
|
||||
password: systemConfig.email?.smtp?.pass || "",
|
||||
from: systemConfig.email?.from || "",
|
||||
},
|
||||
},
|
||||
pagination: {
|
||||
itemsPerPage: appSetup?.paginationItemsPerPage || 10,
|
||||
},
|
||||
};
|
||||
|
||||
// Cache for 5 minutes
|
||||
await setCached(cacheKeys.adminSetup, setupData, 300);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
data: setupData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching admin setup:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to fetch configuration" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await validateSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { googleAuth, googleCalendar, socials, email, pagination } = body;
|
||||
|
||||
// Update database for public-facing settings
|
||||
await prisma.appSetup.upsert({
|
||||
where: { id: 1 },
|
||||
update: {
|
||||
googleAuthEnabled: googleAuth?.enabled || false,
|
||||
socials: socials || {},
|
||||
paginationItemsPerPage: pagination?.itemsPerPage || 10,
|
||||
},
|
||||
create: {
|
||||
id: 1,
|
||||
googleAuthEnabled: googleAuth?.enabled || false,
|
||||
socials: socials || {},
|
||||
paginationItemsPerPage: pagination?.itemsPerPage || 10,
|
||||
categories: ["Basics", "Planning", "Tax", "Healthcare", "Advanced"],
|
||||
},
|
||||
});
|
||||
|
||||
// Update system-config.json for sensitive data
|
||||
const currentConfig = await loadSystemConfig();
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
googleAuth: {
|
||||
clientId: googleAuth?.clientId || "",
|
||||
clientSecret: googleAuth?.clientSecret || "",
|
||||
redirectUri: `${process.env.APP_BASE_URL || "http://localhost:3001"}/auth/google/callback`,
|
||||
},
|
||||
googleCalendar: {
|
||||
enabled: googleCalendar?.enabled || false,
|
||||
serviceAccountEmail: googleCalendar?.serviceAccountEmail || "",
|
||||
serviceAccountKey: googleCalendar?.serviceAccountKey || "",
|
||||
calendarId: googleCalendar?.calendarId || "",
|
||||
},
|
||||
email: {
|
||||
...currentConfig.email,
|
||||
smtp: {
|
||||
enabled: email?.smtp?.enabled || false,
|
||||
host: email?.smtp?.host || "",
|
||||
port: email?.smtp?.port || 587,
|
||||
user: email?.smtp?.username || "",
|
||||
pass: email?.smtp?.password || "",
|
||||
},
|
||||
from: email?.smtp?.from || "",
|
||||
},
|
||||
};
|
||||
|
||||
await saveSystemConfig(updatedConfig, prisma);
|
||||
|
||||
// Invalidate cache after update
|
||||
await deleteCached(cacheKeys.adminSetup);
|
||||
|
||||
console.log("[SETUP] Configuration saved");
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: "Configuration updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating admin setup:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to update configuration" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
166
app/api/admin/users/route.ts
Normal file
166
app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const QuerySchema = z.object({
|
||||
page: z.string().default("1").transform(Number),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
const UpdateUserSchema = z.object({
|
||||
role: z.enum(["USER", "ADMIN"]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return fail(new Error("Unauthorized"), { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const parsed = QuerySchema.safeParse(
|
||||
Object.fromEntries(new URL(req.url).searchParams)
|
||||
);
|
||||
if (!parsed.success) return fail(new Error("Invalid query parameters"));
|
||||
|
||||
const appSetup = await prisma.appSetup.findUnique({ where: { id: 1 } });
|
||||
const pageSize = appSetup?.paginationItemsPerPage || 10;
|
||||
const page = Math.max(1, parsed.data.page);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const searchFilter = parsed.data.search
|
||||
? {
|
||||
OR: [
|
||||
{ email: { contains: parsed.data.search, mode: "insensitive" as const } },
|
||||
{ firstName: { contains: parsed.data.search, mode: "insensitive" as const } },
|
||||
{ lastName: { contains: parsed.data.search, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: searchFilter,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
gender: true,
|
||||
dob: true,
|
||||
address: true,
|
||||
image: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.user.count({ where: searchFilter }),
|
||||
]);
|
||||
|
||||
// Fetch webinars for each user
|
||||
const usersWithWebinars = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const registrations = await prisma.webinarRegistration.findMany({
|
||||
where: { userId: user.id, status: { not: "CANCELLED" } },
|
||||
});
|
||||
return {
|
||||
...user,
|
||||
_count: {
|
||||
webinarRegistrations: registrations.length,
|
||||
},
|
||||
registeredWebinars: await prisma.webinarRegistration.findMany({
|
||||
where: { userId: user.id, status: { not: "CANCELLED" } },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
webinar: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
startAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return ok({
|
||||
users: usersWithWebinars,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
pages: Math.ceil(total / pageSize),
|
||||
hasMore: page < Math.ceil(total / pageSize),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return fail(new Error("Unauthorized"), { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { userId, ...updateData } = body;
|
||||
|
||||
if (!userId) return fail(new Error("userId is required"));
|
||||
|
||||
const parsed = UpdateUserSchema.safeParse(updateData);
|
||||
if (!parsed.success) return fail(new Error("Invalid update data"));
|
||||
|
||||
// Prevent disabling the current admin
|
||||
if (session.sub === userId && parsed.data.isActive === false) {
|
||||
return fail(new Error("Cannot disable your own account"), { status: 400 });
|
||||
}
|
||||
|
||||
// Prevent removing admin role from self
|
||||
if (session.sub === userId && parsed.data.role && parsed.data.role !== "ADMIN") {
|
||||
return fail(new Error("Cannot change your own role"), { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(parsed.data.role && { role: parsed.data.role }),
|
||||
...(parsed.data.isActive !== undefined && { isActive: parsed.data.isActive }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
return ok({
|
||||
message:
|
||||
parsed.data.isActive === false
|
||||
? "User blocked successfully"
|
||||
: "User updated successfully",
|
||||
user,
|
||||
});
|
||||
}
|
||||
17
app/api/auth/[...route]/route.ts
Normal file
17
app/api/auth/[...route]/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const auth = await getAuth();
|
||||
return auth.handler(req);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handler(req);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handler(req);
|
||||
}
|
||||
16
app/api/auth/captcha/route.ts
Normal file
16
app/api/auth/captcha/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { generateCaptcha } from "../../../../lib/captcha";
|
||||
import { ok } from "../../../../lib/http";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { id, code } = generateCaptcha(Date.now().toString());
|
||||
|
||||
// Log the code to console for dev/demo purposes
|
||||
console.log(`CAPTCHA Code: ${code}`);
|
||||
|
||||
return ok({
|
||||
captchaId: id,
|
||||
captchaCode: code, // Return the code to display to user
|
||||
message: "CAPTCHA code generated. Enter it below to continue.",
|
||||
});
|
||||
}
|
||||
64
app/api/auth/change-password/route.ts
Normal file
64
app/api/auth/change-password/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { hashPassword, verifyPassword } from "../../../../lib/auth/password";
|
||||
import { isStrongPassword } from "../../../../lib/auth/validation";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
confirmPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503, isAdmin: session.role === "ADMIN" });
|
||||
|
||||
const parsed = Body.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) return fail(new Error("Invalid input"));
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.sub },
|
||||
include: { credential: true }
|
||||
});
|
||||
if (!user || !user.credential) return fail(new Error("Invalid user or no password set"));
|
||||
|
||||
if (parsed.data.newPassword !== parsed.data.confirmPassword) {
|
||||
return fail(new Error("Passwords do not match"));
|
||||
}
|
||||
if (!isStrongPassword(parsed.data.newPassword)) {
|
||||
return fail(new Error("Password is not strong enough"));
|
||||
}
|
||||
|
||||
// 🔥 Only verify current password if NOT forced reset
|
||||
if (!user.forcePasswordReset) {
|
||||
const okPw = await verifyPassword(
|
||||
parsed.data.currentPassword,
|
||||
user.credential.password
|
||||
);
|
||||
if (!okPw) return fail(new Error("Invalid password"));
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(parsed.data.newPassword);
|
||||
await prisma.credential.update({
|
||||
where: { userId: user.id },
|
||||
data: { password: newHash },
|
||||
});
|
||||
|
||||
// Clear force password reset flag if it was set
|
||||
if (user.forcePasswordReset) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { forcePasswordReset: false },
|
||||
});
|
||||
}
|
||||
|
||||
return ok({ message: "Password updated" });
|
||||
}
|
||||
108
app/api/auth/login/route.ts
Normal file
108
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { verifyPassword } from "../../../../lib/auth/password";
|
||||
import { signSession } from "../../../../lib/auth/jwt";
|
||||
import { verifyCaptcha } from "../../../../lib/captcha";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
captchaId: z.string().optional(),
|
||||
captchaCode: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const prisma = await getPrisma();
|
||||
const body = Body.safeParse(await req.json().catch(() => ({})));
|
||||
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ ok: false, message: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ ok: false, message: "Database not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
// Validate CAPTCHA if provided
|
||||
if (body.data.captchaId && body.data.captchaCode) {
|
||||
const captchaResult = verifyCaptcha(body.data.captchaId, body.data.captchaCode);
|
||||
if (!captchaResult.success) {
|
||||
return NextResponse.json({ ok: false, message: captchaResult.error || "CAPTCHA verification failed. Please try again." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const email = body.data.email.toLowerCase();
|
||||
const password = body.data.password;
|
||||
|
||||
console.log("[LOGIN] Login attempt for:", email);
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: { credential: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log("[LOGIN] User not found:", email);
|
||||
return NextResponse.json({ ok: false, message: "Invalid email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.credential || !user.credential.password) {
|
||||
console.log("[LOGIN] No password credential for user:", email);
|
||||
return NextResponse.json({ ok: false, message: "Please use Google sign-in for this account" }, { status: 401 });
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.credential.password);
|
||||
|
||||
if (!valid) {
|
||||
console.log("[LOGIN] Invalid password for:", email);
|
||||
return NextResponse.json({ ok: false, message: "Invalid email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
console.log("[LOGIN] Inactive account:", email);
|
||||
return NextResponse.json({ ok: false, message: "Account is inactive. Please contact support." }, { status: 403 });
|
||||
}
|
||||
|
||||
// Create session token
|
||||
const token = await signSession({
|
||||
sub: user.id,
|
||||
role: user.role as "ADMIN" | "USER",
|
||||
email: user.email,
|
||||
forcePasswordReset: user.forcePasswordReset || false,
|
||||
});
|
||||
|
||||
console.log("[LOGIN] Session created for:", email, "with role:", user.role);
|
||||
|
||||
// Set cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("ep_session", token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
path: "/",
|
||||
});
|
||||
|
||||
console.log("[LOGIN] Login successful for:", email);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
emailVerified: user.emailVerified,
|
||||
forcePasswordReset: user.forcePasswordReset,
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("[LOGIN] Error:", e);
|
||||
return NextResponse.json({ ok: false, message: "Server error: Unable to process request" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
23
app/api/auth/logout/route.ts
Normal file
23
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "../../../../lib/auth/session";
|
||||
import { ok } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
console.log("[LOGOUT] User logout");
|
||||
|
||||
const res = ok({ message: "Logged out successfully" });
|
||||
|
||||
// Clear the session cookie
|
||||
res.cookies.set(SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
console.log("[LOGOUT] Session cleared");
|
||||
return res;
|
||||
}
|
||||
27
app/api/auth/me/route.ts
Normal file
27
app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ok } from "../../../../lib/http";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) return ok({ session: null });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return ok({ session });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.sub } });
|
||||
return ok({
|
||||
session,
|
||||
user: user
|
||||
? {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
avatarUrl: user.image,
|
||||
role: user.role,
|
||||
email: user.email,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
124
app/api/auth/register/route.ts
Normal file
124
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { hashPassword } from "../../../../lib/auth/password";
|
||||
import { isStrongPassword, sanitizeText } from "../../../../lib/auth/validation";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
import { loadSystemConfig } from "../../../../lib/system-config";
|
||||
import { sendEmail } from "../../../../lib/email";
|
||||
import { verifyCaptcha } from "../../../../lib/captcha";
|
||||
import crypto from "crypto";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
gender: z.string().optional(),
|
||||
dob: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string().min(8),
|
||||
captchaId: z.string().optional(),
|
||||
captchaCode: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const cfg = await loadSystemConfig();
|
||||
const prisma = await getPrisma();
|
||||
const body = Body.safeParse(await req.json().catch(() => ({})));
|
||||
|
||||
if (!body.success) return fail(new Error("Invalid input"));
|
||||
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
// Validate CAPTCHA if provided
|
||||
if (body.data.captchaId && body.data.captchaCode) {
|
||||
const captchaResult = verifyCaptcha(body.data.captchaId, body.data.captchaCode);
|
||||
if (!captchaResult.success) {
|
||||
return fail(new Error(captchaResult.error || "CAPTCHA verification failed. Please try again."));
|
||||
}
|
||||
}
|
||||
|
||||
const email = body.data.email.toLowerCase();
|
||||
const password = body.data.password;
|
||||
const confirmPassword = body.data.confirmPassword;
|
||||
|
||||
if (password !== confirmPassword) return fail(new Error("Passwords do not match"));
|
||||
if (!isStrongPassword(password)) return fail(new Error("Password is not strong enough"));
|
||||
|
||||
// Validate birth date if provided
|
||||
if (body.data.dob) {
|
||||
const birthDate = new Date(body.data.dob);
|
||||
const today = new Date();
|
||||
const minDate = new Date(today.getFullYear() - 100, today.getMonth(), today.getDate());
|
||||
const maxDate = new Date(today.getFullYear() - 18, today.getMonth(), today.getDate());
|
||||
if (birthDate < minDate || birthDate > maxDate) {
|
||||
return fail(new Error("Birth date must be between 18 and 100 years old"));
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
try {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
role: "USER",
|
||||
name: `${body.data.firstName} ${body.data.lastName}`,
|
||||
firstName: sanitizeText(body.data.firstName),
|
||||
lastName: sanitizeText(body.data.lastName),
|
||||
gender: body.data.gender ? sanitizeText(body.data.gender) : null,
|
||||
dob: body.data.dob ? new Date(body.data.dob) : null,
|
||||
address: body.data.address ? sanitizeText(body.data.address) : null,
|
||||
// If email is enabled, require verification; otherwise mark as verified
|
||||
emailVerified: !cfg.email?.enabled,
|
||||
forcePasswordReset: false,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create credential for email/password authentication
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
password: passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
// Only create verification token and send email if email is enabled
|
||||
if (cfg.email?.enabled) {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await prisma.verification.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
identifier: email,
|
||||
value: token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const baseUrl = process.env.APP_BASE_URL || "http://localhost:3000";
|
||||
const link = `${baseUrl}/auth/verify?token=${token}`;
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: "Verify your email",
|
||||
html: `<p>Welcome! Please verify your email:</p><p><a href="${link}">Verify Email</a></p>`,
|
||||
});
|
||||
return ok({ message: "Account created. Please check your email to verify your account." });
|
||||
} else {
|
||||
return ok({ message: "Account created successfully. You can now log in." });
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Check if the error is due to unique constraint on email
|
||||
if (e.code === 'P2002' && e.meta?.target?.includes('email')) {
|
||||
return fail(new Error("This email is already taken"));
|
||||
}
|
||||
console.error("Registration error:", e);
|
||||
return fail(new Error("Account creation failed. Please try again or contact support."));
|
||||
}
|
||||
|
||||
return ok({ message: "Account created. Please check your email to verify your account." });
|
||||
}
|
||||
63
app/api/contact/route.ts
Normal file
63
app/api/contact/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../lib/db";
|
||||
import { ok, fail } from "../../../lib/http";
|
||||
|
||||
const ContactSchema = z.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
message: z.string().min(10, "Message must be at least 10 characters"),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const parsed = ContactSchema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
console.error("[CONTACT] Validation error:", parsed.error);
|
||||
const firstError = parsed.error.errors[0];
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: firstError.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { firstName, lastName, email, subject, message } = parsed.data;
|
||||
const name = `${firstName} ${lastName}`;
|
||||
console.log("[CONTACT] New message from:", email);
|
||||
|
||||
try {
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
console.error("[CONTACT] Database not configured");
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Service temporarily unavailable. Please try again later." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Store contact message in database
|
||||
await prisma.contactMessage.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
subject,
|
||||
message,
|
||||
status: "NEW",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[CONTACT] Message saved from:", email);
|
||||
|
||||
// TODO: Send email notification to admin
|
||||
// TODO: Send confirmation email to user
|
||||
|
||||
return ok({ message: "Message received. We'll get back to you soon." });
|
||||
} catch (error) {
|
||||
console.error("[CONTACT] Error saving message:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to send message. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/api/public/app-setup/route.ts
Normal file
59
app/api/public/app-setup/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadSystemConfig } from "@/lib/system-config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const appSetup = await prisma.appSetup.findUnique({
|
||||
where: { id: 1 },
|
||||
select: {
|
||||
googleAuthEnabled: true,
|
||||
socials: true,
|
||||
},
|
||||
});
|
||||
|
||||
const systemConfig = await loadSystemConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
setup: {
|
||||
data: {
|
||||
googleAuthEnabled: appSetup?.googleAuthEnabled || false,
|
||||
googleClientId: systemConfig.oauth?.google?.clientId || "",
|
||||
socials: appSetup?.socials || {},
|
||||
smtp: {
|
||||
enabled: systemConfig.email?.enabled || false,
|
||||
},
|
||||
oauth: {
|
||||
google: {
|
||||
enabled: systemConfig.oauth?.google?.enabled || false,
|
||||
clientId: systemConfig.oauth?.google?.clientId || "",
|
||||
},
|
||||
github: {
|
||||
enabled: systemConfig.oauth?.github?.enabled || false,
|
||||
clientId: systemConfig.oauth?.github?.clientId || "",
|
||||
},
|
||||
facebook: {
|
||||
enabled: systemConfig.oauth?.facebook?.enabled || false,
|
||||
clientId: systemConfig.oauth?.facebook?.clientId || "",
|
||||
},
|
||||
discord: {
|
||||
enabled: systemConfig.oauth?.discord?.enabled || false,
|
||||
clientId: systemConfig.oauth?.discord?.clientId || "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching app setup:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to fetch configuration" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
124
app/api/registrations/route.ts
Normal file
124
app/api/registrations/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSession } from "../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../lib/db";
|
||||
import { ok, fail } from "../../../lib/http";
|
||||
import { getStripe } from "../../../lib/stripe";
|
||||
import { loadSystemConfig } from "../../../lib/system-config";
|
||||
import { sendEmail } from "../../../lib/email";
|
||||
import { createWebinarCalendarEvent } from "../../../lib/calendar";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
webinarId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
// Check if user is admin - prevent admin registration for webinars
|
||||
const user = await prisma.user.findUnique({ where: { id: session.sub } });
|
||||
if (user?.role === "ADMIN") {
|
||||
return fail(new Error("Admins cannot register for webinars"), { status: 403 });
|
||||
}
|
||||
|
||||
const parsed = Body.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) return fail(new Error("Invalid input"));
|
||||
|
||||
const webinar = await prisma.webinar.findUnique({ where: { id: parsed.data.webinarId } });
|
||||
if (!webinar || webinar.visibility !== "PUBLIC" || !webinar.isActive) return fail(new Error("Not found"), { status: 404 });
|
||||
|
||||
const count = await prisma.webinarRegistration.count({
|
||||
where: { webinarId: webinar.id, status: { not: "CANCELLED" } },
|
||||
});
|
||||
if (count >= webinar.capacity) return fail(new Error("Webinar is full"), { status: 409 });
|
||||
|
||||
// Upsert registration
|
||||
if (webinar.priceCents <= 0) {
|
||||
const reg = await prisma.webinarRegistration.upsert({
|
||||
where: { userId_webinarId: { userId: session.sub, webinarId: webinar.id } },
|
||||
update: { status: "CONFIRMED" },
|
||||
create: { userId: session.sub, webinarId: webinar.id, status: "CONFIRMED" },
|
||||
});
|
||||
|
||||
// Send confirmation email with calendar invite for free webinars
|
||||
const cfg = await loadSystemConfig();
|
||||
if (cfg.email?.enabled && user) {
|
||||
try {
|
||||
const { icsContent } = await createWebinarCalendarEvent(webinar, user.email);
|
||||
|
||||
const meetingInfo = webinar.meetingInfo as any;
|
||||
const meetingLink = meetingInfo?.meetingLink || "TBD";
|
||||
const htmlContent = `
|
||||
<h2>Webinar Registration Confirmed</h2>
|
||||
<p>Hi ${user.firstName},</p>
|
||||
<p>Thank you for registering for <strong>${webinar.title}</strong>.</p>
|
||||
<p><strong>Date & Time:</strong> ${new Date(webinar.startAt).toLocaleString()}</p>
|
||||
<p><strong>Duration:</strong> ${webinar.duration} minutes</p>
|
||||
<p><strong>Join Link:</strong> <a href="${meetingLink}">${meetingLink}</a></p>
|
||||
<p>A calendar invitation is attached to this email.</p>
|
||||
<p>See you there!</p>
|
||||
`;
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: `Registration Confirmed: ${webinar.title}`,
|
||||
html: htmlContent,
|
||||
attachments: [
|
||||
{
|
||||
filename: "webinar-invite.ics",
|
||||
content: icsContent,
|
||||
contentType: "text/calendar",
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[REGISTRATION] Failed to send email:", error);
|
||||
// Don't fail the registration if email fails
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ registration: reg, next: "CONFIRMED" });
|
||||
}
|
||||
|
||||
const cfg = await loadSystemConfig();
|
||||
const stripe = await getStripe();
|
||||
if (!stripe) return fail(new Error("Stripe not configured"), { status: 503 });
|
||||
|
||||
const baseUrl = cfg.app?.initialized ? (process.env.APP_BASE_URL || "http://localhost:3000") : (process.env.APP_BASE_URL || "http://localhost:3000");
|
||||
|
||||
const reg = await prisma.webinarRegistration.upsert({
|
||||
where: { userId_webinarId: { userId: session.sub, webinarId: webinar.id } },
|
||||
update: { status: "PAYMENT_PENDING" },
|
||||
create: { userId: session.sub, webinarId: webinar.id, status: "PAYMENT_PENDING" },
|
||||
});
|
||||
|
||||
const checkout = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
success_url: `${baseUrl}/webinars/${webinar.id}?payment=success`,
|
||||
cancel_url: `${baseUrl}/webinars/${webinar.id}?payment=cancel`,
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
unit_amount: webinar.priceCents,
|
||||
product_data: { name: webinar.title },
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: { registrationId: reg.id, userId: session.sub, webinarId: webinar.id },
|
||||
});
|
||||
|
||||
await prisma.webinarRegistration.update({
|
||||
where: { id: reg.id },
|
||||
data: { stripeCheckoutSessionId: checkout.id },
|
||||
});
|
||||
|
||||
return ok({ next: "STRIPE_CHECKOUT", url: checkout.url });
|
||||
}
|
||||
94
app/api/stripe/webhook/route.ts
Normal file
94
app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { loadSystemConfig } from "../../../../lib/system-config";
|
||||
import { getStripe } from "../../../../lib/stripe";
|
||||
import { sendEmail } from "../../../../lib/email";
|
||||
import { createWebinarCalendarEvent } from "../../../../lib/calendar";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const prisma = await getPrisma();
|
||||
const stripe = await getStripe();
|
||||
const cfg = await loadSystemConfig();
|
||||
|
||||
const secret = cfg.stripe?.webhookSecret || process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!prisma || !stripe || !secret) {
|
||||
// must still 200 to avoid retries in misconfigured env
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const headersList = await headers();
|
||||
const sig = headersList.get("stripe-signature");
|
||||
const body = await req.text();
|
||||
|
||||
if (!sig) return NextResponse.json({ ok: true });
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, sig, secret);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as any;
|
||||
const registrationId = session.metadata?.registrationId as string | undefined;
|
||||
const paymentIntent = session.payment_intent as string | undefined;
|
||||
|
||||
if (registrationId) {
|
||||
try {
|
||||
await prisma.webinarRegistration.update({
|
||||
where: { id: registrationId },
|
||||
data: { status: "PAID", stripePaymentIntentId: paymentIntent ?? null },
|
||||
});
|
||||
|
||||
// Send confirmation email with calendar invite
|
||||
const registration = await prisma.webinarRegistration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: { user: true, webinar: true },
|
||||
});
|
||||
|
||||
if (registration && cfg.email?.enabled) {
|
||||
const { icsContent } = await createWebinarCalendarEvent(
|
||||
registration.webinar,
|
||||
registration.user.email
|
||||
);
|
||||
|
||||
const meetingInfo = registration.webinar.meetingInfo as any;
|
||||
const meetingLink = meetingInfo?.meetingLink || "TBD";
|
||||
const htmlContent = `
|
||||
<h2>Webinar Registration Confirmed</h2>
|
||||
<p>Hi ${registration.user.firstName},</p>
|
||||
<p>Thank you for registering for <strong>${registration.webinar.title}</strong>.</p>
|
||||
<p><strong>Date & Time:</strong> ${new Date(registration.webinar.startAt).toLocaleString()}</p>
|
||||
<p><strong>Duration:</strong> ${registration.webinar.duration} minutes</p>
|
||||
<p><strong>Join Link:</strong> <a href="${meetingLink}">${meetingLink}</a></p>
|
||||
<p>A calendar invitation is attached to this email.</p>
|
||||
<p>See you there!</p>
|
||||
`;
|
||||
|
||||
await sendEmail({
|
||||
to: registration.user.email,
|
||||
subject: `Registration Confirmed: ${registration.webinar.title}`,
|
||||
html: htmlContent,
|
||||
attachments: [
|
||||
{
|
||||
filename: "webinar-invite.ics",
|
||||
content: icsContent,
|
||||
contentType: "text/calendar",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[WEBHOOK] Error processing payment:", error);
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
142
app/api/webinars/[id]/route.ts
Normal file
142
app/api/webinars/[id]/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const UpdateBody = z.object({
|
||||
title: z.string().min(3).optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
speaker: z.string().min(1).optional(),
|
||||
startAt: z.string().optional(),
|
||||
duration: z.number().int().positive().optional(),
|
||||
bannerUrl: z.string().url().optional().or(z.literal("")).optional(),
|
||||
category: z.string().min(1).optional(),
|
||||
visibility: z.enum(["PUBLIC", "PRIVATE"]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
capacity: z.number().int().positive().optional(),
|
||||
priceCents: z.number().int().min(0).optional(),
|
||||
learningPoints: z.array(z.string()).optional(),
|
||||
meetingInfo: z.any().optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ ok: false, message: "Database not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const isAdmin = session?.role === "ADMIN";
|
||||
|
||||
try {
|
||||
const webinar = await prisma.webinar.findUnique({
|
||||
where: { id: id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { registrations: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!webinar) {
|
||||
return NextResponse.json({ ok: false, message: "Webinar not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check visibility
|
||||
if (!isAdmin && (webinar.visibility !== "PUBLIC" || !webinar.isActive)) {
|
||||
return NextResponse.json({ ok: false, message: "Webinar not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, webinar });
|
||||
} catch (error) {
|
||||
console.error("Error fetching webinar:", error);
|
||||
return NextResponse.json({ ok: false, message: "Failed to fetch webinar" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return NextResponse.json({ ok: false, message: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ ok: false, message: "Database not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const parsed = UpdateBody.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Invalid input", errors: parsed.error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const updateData: any = { ...parsed.data };
|
||||
|
||||
if (parsed.data.startAt) {
|
||||
updateData.startAt = new Date(parsed.data.startAt);
|
||||
}
|
||||
|
||||
if (parsed.data.learningPoints) {
|
||||
updateData.learningPoints = parsed.data.learningPoints;
|
||||
}
|
||||
|
||||
const webinar = await prisma.webinar.update({
|
||||
where: { id: id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, webinar, message: "Webinar updated successfully" });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating webinar:", error);
|
||||
if (error.code === "P2025") {
|
||||
return NextResponse.json({ ok: false, message: "Webinar not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: false, message: "Failed to update webinar" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return NextResponse.json({ ok: false, message: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ ok: false, message: "Database not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.webinar.delete({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, message: "Webinar deleted successfully" });
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting webinar:", error);
|
||||
if (error.code === "P2025") {
|
||||
return NextResponse.json({ ok: false, message: "Webinar not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: false, message: "Failed to delete webinar" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
72
app/api/webinars/route.ts
Normal file
72
app/api/webinars/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../lib/db";
|
||||
import { getSession } from "../../../lib/auth/session";
|
||||
import { ok, fail } from "../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const session = await getSession();
|
||||
const isAdmin = session?.role === "ADMIN";
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get("page") || "0");
|
||||
const limit = parseInt(url.searchParams.get("limit") || "20");
|
||||
const offset = page * limit;
|
||||
|
||||
const where = isAdmin ? {} : { visibility: "PUBLIC" as const, isActive: true };
|
||||
|
||||
const [webinars, total] = await Promise.all([
|
||||
prisma.webinar.findMany({
|
||||
where,
|
||||
orderBy: { startAt: "asc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.webinar.count({ where }),
|
||||
]);
|
||||
|
||||
return ok({ webinars, total, page, limit });
|
||||
}
|
||||
|
||||
const CreateBody = z.object({
|
||||
title: z.string().min(3),
|
||||
description: z.string().min(1),
|
||||
speaker: z.string().min(1),
|
||||
startAt: z.string(),
|
||||
duration: z.number().int().positive(),
|
||||
bannerUrl: z.string().url().optional().or(z.literal("")),
|
||||
category: z.string().min(1),
|
||||
visibility: z.enum(["PUBLIC", "PRIVATE"]),
|
||||
isActive: z.boolean(),
|
||||
capacity: z.number().int().positive(),
|
||||
priceCents: z.number().int().min(0),
|
||||
learningPoints: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") return fail(new Error("Forbidden"), { status: 403 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503, isAdmin: true });
|
||||
|
||||
const parsed = CreateBody.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) return fail(new Error("Invalid input"), { isAdmin: true });
|
||||
|
||||
const w = await prisma.webinar.create({
|
||||
data: {
|
||||
...parsed.data,
|
||||
bannerUrl: parsed.data.bannerUrl ? parsed.data.bannerUrl : null,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
meetingInfo: {},
|
||||
learningPoints: parsed.data.learningPoints || [],
|
||||
},
|
||||
});
|
||||
|
||||
return ok({ webinar: w });
|
||||
}
|
||||
63
app/auth-callback/page.tsx
Normal file
63
app/auth-callback/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
function AuthCallbackContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Get the redirect URL from query params
|
||||
const redirectUrl = searchParams.get("redirect") || "/account/webinars";
|
||||
|
||||
// Check if user is authenticated by fetching session
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
|
||||
if (data.user) {
|
||||
// User is authenticated, redirect to the specified URL
|
||||
window.location.href = redirectUrl;
|
||||
} else {
|
||||
// Not authenticated, redirect to signin
|
||||
window.location.href = `/signin?redirect=${encodeURIComponent(redirectUrl)}`;
|
||||
}
|
||||
} catch (error) {
|
||||
// Error checking auth, redirect to signin
|
||||
window.location.href = `/signin?redirect=${encodeURIComponent(redirectUrl)}`;
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary via-primary-dark to-primary-dark">
|
||||
<div className="text-center text-white space-y-4">
|
||||
<div className="inline-block animate-spin">
|
||||
<div className="text-6xl">⏳</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">Authenticating...</h1>
|
||||
<p className="text-white/80">Please wait while we complete your authentication</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary via-primary-dark to-primary-dark">
|
||||
<div className="text-center text-white space-y-4">
|
||||
<div className="inline-block animate-spin">
|
||||
<div className="text-6xl">⏳</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">Loading...</h1>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<AuthCallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
25
app/auth/discord/callback/route.ts
Normal file
25
app/auth/discord/callback/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getAuth();
|
||||
const response = await auth.handler(req);
|
||||
|
||||
const redirectUrl = req.nextUrl.searchParams.get("redirect") || "/account/webinars";
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (location) {
|
||||
return NextResponse.redirect(new URL(`/auth-callback?redirect=${encodeURIComponent(redirectUrl)}`, req.url));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Discord callback error:", error);
|
||||
return new Response("Authentication failed", { status: 500 });
|
||||
}
|
||||
}
|
||||
25
app/auth/facebook/callback/route.ts
Normal file
25
app/auth/facebook/callback/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getAuth();
|
||||
const response = await auth.handler(req);
|
||||
|
||||
const redirectUrl = req.nextUrl.searchParams.get("redirect") || "/account/webinars";
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (location) {
|
||||
return NextResponse.redirect(new URL(`/auth-callback?redirect=${encodeURIComponent(redirectUrl)}`, req.url));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Facebook callback error:", error);
|
||||
return new Response("Authentication failed", { status: 500 });
|
||||
}
|
||||
}
|
||||
25
app/auth/github/callback/route.ts
Normal file
25
app/auth/github/callback/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getAuth();
|
||||
const response = await auth.handler(req);
|
||||
|
||||
const redirectUrl = req.nextUrl.searchParams.get("redirect") || "/account/webinars";
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (location) {
|
||||
return NextResponse.redirect(new URL(`/auth-callback?redirect=${encodeURIComponent(redirectUrl)}`, req.url));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("GitHub callback error:", error);
|
||||
return new Response("Authentication failed", { status: 500 });
|
||||
}
|
||||
}
|
||||
30
app/auth/google/callback/route.ts
Normal file
30
app/auth/google/callback/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getAuth();
|
||||
const response = await auth.handler(req);
|
||||
|
||||
// Get redirect URL from query params if provided
|
||||
const redirectUrl = req.nextUrl.searchParams.get("redirect") || "/account/webinars";
|
||||
|
||||
// If response is a redirect, extract location and modify it
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (location) {
|
||||
// If it's redirecting to a callback page, preserve the redirect param
|
||||
return NextResponse.redirect(new URL(`/auth-callback?redirect=${encodeURIComponent(redirectUrl)}`, req.url));
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise return auth response as is, then frontend will handle redirect
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Google callback error:", error);
|
||||
return new Response("Authentication failed", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
51
app/auth/google/route.ts
Normal file
51
app/auth/google/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadSystemConfig } from "@/lib/system-config";
|
||||
import crypto from "crypto";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const systemConfig = await loadSystemConfig();
|
||||
const { googleAuth } = systemConfig;
|
||||
|
||||
if (!googleAuth?.clientId || !googleAuth?.clientSecret) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Google OAuth not configured" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate CSRF token
|
||||
const state = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// Store state in cookie for verification
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("oauth_state", state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 600, // 10 minutes
|
||||
});
|
||||
|
||||
// Build Google OAuth URL
|
||||
const params = new URLSearchParams({
|
||||
client_id: googleAuth.clientId,
|
||||
redirect_uri: `${process.env.APP_BASE_URL || "http://localhost:3001"}/auth/google/callback`,
|
||||
response_type: "code",
|
||||
scope: "openid email profile",
|
||||
state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
|
||||
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
||||
|
||||
return NextResponse.redirect(authUrl);
|
||||
} catch (error) {
|
||||
console.error("Google OAuth error:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to initiate OAuth" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
198
app/contact/page.tsx
Normal file
198
app/contact/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ContactPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSubmitted(true);
|
||||
setFormData({ firstName: '', lastName: '', email: '', subject: '', message: '' });
|
||||
setTimeout(() => setSubmitted(false), 5000);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.message || 'Failed to send message. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again later.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900" />
|
||||
<div className="absolute -top-24 right-0 h-64 w-64 rounded-full bg-primary/15 blur-3xl" />
|
||||
<div className="absolute -bottom-24 left-0 h-64 w-64 rounded-full bg-secondary/15 blur-3xl" />
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-6 py-16 lg:py-20">
|
||||
<div className="text-center mb-12">
|
||||
<p className="inline-flex items-center gap-2 text-sm font-semibold tracking-wide uppercase text-primary/80 bg-primary/10 px-3 py-1 rounded-full">
|
||||
Have some questions?
|
||||
</p>
|
||||
<h1 className="mt-4 text-4xl md:text-5xl font-bold text-gray-900 dark:text-white">
|
||||
Get in touch with our team
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
We’ll help you choose the right webinar and guide you through planning your next steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[1.1fr_1.4fr] gap-8">
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 backdrop-blur-md p-8 shadow-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Contact details</h2>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Reach out directly or send a message using the form.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-xl bg-slate-50 dark:bg-slate-800/60 p-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">📧</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Email</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">support@estateplanning.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-xl bg-slate-50 dark:bg-slate-800/60 p-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">📞</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Phone</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">+1 (555) 123-4567</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-xl bg-slate-50 dark:bg-slate-800/60 p-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">📍</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Office</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">123 Main St, Suite 100</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-2xl overflow-hidden border border-gray-200/70 dark:border-slate-800/70">
|
||||
<div className="h-40 bg-gradient-to-br from-primary/20 via-transparent to-secondary/20 flex items-center justify-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Map preview coming soon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white dark:bg-slate-900 p-8 shadow-xl">
|
||||
{submitted && (
|
||||
<div className="mb-6 p-4 bg-success/10 border border-success text-success rounded-lg">
|
||||
✓ Thank you for your message! We'll get back to you soon.
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-danger/10 border border-danger text-danger rounded-lg">
|
||||
✗ {error}
|
||||
</div>
|
||||
)}
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-200">First Name *</label>
|
||||
<input
|
||||
className="input-field"
|
||||
type="text"
|
||||
name="firstName"
|
||||
placeholder="John"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-200">Last Name *</label>
|
||||
<input
|
||||
className="input-field"
|
||||
type="text"
|
||||
name="lastName"
|
||||
placeholder="Doe"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-200">Email *</label>
|
||||
<input
|
||||
className="input-field"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="john@example.com"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-200">Subject *</label>
|
||||
<input
|
||||
className="input-field"
|
||||
type="text"
|
||||
name="subject"
|
||||
placeholder="How can we help you?"
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-200">Message *</label>
|
||||
<textarea
|
||||
className="input-field"
|
||||
name="message"
|
||||
rows={6}
|
||||
placeholder="Tell us more about your inquiry..."
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Message'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
238
app/globals.css
Normal file
238
app/globals.css
Normal file
@@ -0,0 +1,238 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 255, 255, 255;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 15, 23, 42;
|
||||
--background-end-rgb: 15, 23, 42;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Modern Button Styles */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg font-semibold bg-gradient-to-r from-primary to-primary-dark text-white shadow-md transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed border border-primary-dark/20;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg font-semibold bg-gradient-to-r from-secondary to-secondary-dark text-white shadow-md transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed border border-secondary-dark/20;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg font-semibold border-2 border-primary text-primary bg-white dark:bg-slate-800 transition-all duration-300 hover:bg-primary/5 dark:hover:bg-primary/10 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium text-gray-700 dark:text-gray-300 transition-all duration-300 hover:bg-gray-100 dark:hover:bg-slate-700 active:scale-95 hover:-translate-y-0.5;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg font-semibold bg-gradient-to-r from-danger to-danger-dark text-white shadow-md transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed border border-danger-dark/20;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-4 py-2 text-sm;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply px-8 py-4 text-lg;
|
||||
}
|
||||
|
||||
/* Modern Input Styles */
|
||||
.input-field {
|
||||
@apply w-full px-4 py-3 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:border-primary focus:ring-2 focus:ring-primary/30 transition-all duration-300 outline-none shadow-sm;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-danger focus:border-danger focus:ring-danger/30;
|
||||
}
|
||||
|
||||
/* Modern Card Styles */
|
||||
.card {
|
||||
@apply bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 transition-all duration-300 overflow-hidden;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply shadow-md border-gray-300 dark:border-slate-600;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply hover:shadow-elevation-2 hover:-translate-y-1 cursor-pointer;
|
||||
}
|
||||
|
||||
/* Section Container */
|
||||
.section-container {
|
||||
@apply py-16 md:py-20 lg:py-28;
|
||||
}
|
||||
|
||||
/* Badge Styles */
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold bg-primary/10 text-primary dark:bg-primary/20;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success/10 text-success dark:bg-success/20;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-danger/10 text-danger dark:bg-danger/20;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning/10 text-warning dark:bg-warning/20;
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.hover-lift {
|
||||
@apply transition-transform duration-300 hover:-translate-y-1;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-slideDown {
|
||||
animation: slideDown 0.8s ease-out;
|
||||
}
|
||||
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.8s ease-out;
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse-soft {
|
||||
animation: pulseSoft 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Modern Modal Styles */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity duration-300;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply relative z-10 w-full max-w-3xl max-h-[90vh] overflow-y-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply flex items-center justify-between px-6 py-4 bg-gradient-to-r from-primary/5 to-secondary/5 dark:from-primary/10 dark:to-secondary/10 border-b border-gray-200 dark:border-slate-700;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@apply text-xl font-bold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
@apply inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-all duration-200 hover:text-gray-700 dark:hover:text-gray-200;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@apply px-6 py-6 space-y-4;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply px-6 py-4 bg-gray-50 dark:bg-slate-700/50 border-t border-gray-200 dark:border-slate-700 flex gap-3 justify-end;
|
||||
}
|
||||
|
||||
/* Form input improvements */
|
||||
.input {
|
||||
@apply input-field;
|
||||
}
|
||||
|
||||
.input-with-icon {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
@apply absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseSoft {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-slate-900;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-400 dark:bg-slate-600 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-500 dark:bg-slate-500;
|
||||
}
|
||||
41
app/layout.tsx
Normal file
41
app/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import './globals.css'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import Footer from '@/components/Footer'
|
||||
import Providers from '@/components/Providers'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Estate Planning Webinars - Learn From Experts',
|
||||
description: 'Join expert-led webinars on estate planning, wills, trusts, and asset protection. Free and premium courses available.',
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
apple: '/images/icon-192.png',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</head>
|
||||
<body>
|
||||
<Providers>
|
||||
<Navbar />
|
||||
{children}
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
17
app/page.tsx
Normal file
17
app/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Hero from "../components/Hero";
|
||||
import WhyWithUs from "../components/WhyWithUs";
|
||||
import UpcomingWebinars from "../components/UpcomingWebinars";
|
||||
import Testimonials from "../components/Testimonials";
|
||||
import CTA from "../components/CTA";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<Hero />
|
||||
<WhyWithUs />
|
||||
<UpcomingWebinars />
|
||||
<Testimonials />
|
||||
<CTA />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
193
app/resources/page.tsx
Normal file
193
app/resources/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const resources = [
|
||||
{
|
||||
id: 1,
|
||||
icon: "📄",
|
||||
category: "Guide",
|
||||
title: "Estate Planning Basics",
|
||||
description: "A comprehensive guide to getting started with estate planning",
|
||||
type: "PDF",
|
||||
downloads: 1240,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: "📋",
|
||||
category: "Template",
|
||||
title: "Will Template",
|
||||
description: "Customizable template to help you create your will",
|
||||
type: "Document",
|
||||
downloads: 892,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: "💰",
|
||||
category: "Guide",
|
||||
title: "Tax Planning Guide",
|
||||
description: "Strategies to minimize estate taxes and maximize inheritance",
|
||||
type: "PDF",
|
||||
downloads: 567,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: "🏥",
|
||||
category: "Guide",
|
||||
title: "Healthcare Directives",
|
||||
description: "Learn about living wills and healthcare proxies",
|
||||
type: "Checklist",
|
||||
downloads: 734,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: "👨👩👧👦",
|
||||
category: "Template",
|
||||
title: "Trust Planning Guide",
|
||||
description: "Understanding different types of trusts and their benefits",
|
||||
type: "Guide",
|
||||
downloads: 856,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
icon: "🎓",
|
||||
category: "Video",
|
||||
title: "Video Library",
|
||||
description: "Access our collection of educational videos",
|
||||
type: "Video",
|
||||
downloads: 1450,
|
||||
},
|
||||
];
|
||||
|
||||
const categories = ["All", "Guide", "Template", "Video"];
|
||||
|
||||
export default function ResourcesPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
|
||||
const filteredResources =
|
||||
selectedCategory === "All"
|
||||
? resources
|
||||
: resources.filter((r) => r.category === selectedCategory);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-800">
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden px-6 py-20 sm:py-28">
|
||||
{/* Animated background blobs */}
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 h-80 w-80 rounded-full bg-primary/20 mix-blend-multiply filter blur-3xl opacity-20 animate-pulse"></div>
|
||||
<div className="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-secondary/20 mix-blend-multiply filter blur-3xl opacity-20 animate-pulse"></div>
|
||||
<div className="absolute top-1/2 left-1/2 h-80 w-80 rounded-full bg-blue-500/20 mix-blend-multiply filter blur-3xl opacity-20 animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto text-center relative z-10">
|
||||
<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-6">
|
||||
📚 Learning Center
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl sm:text-6xl font-bold text-slate-900 dark:text-white mb-6 leading-tight">
|
||||
Knowledge<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"> Resources</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl sm:text-2xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-8 leading-relaxed">
|
||||
Access essential guides, templates, and tools to master estate planning
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 max-w-2xl mx-auto mt-12">
|
||||
<div className="card p-4 backdrop-blur-xl bg-white/50 dark:bg-slate-800/50 border border-white/20 dark:border-slate-700/20">
|
||||
<div className="text-2xl font-bold text-primary">{resources.length}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Resources</div>
|
||||
</div>
|
||||
<div className="card p-4 backdrop-blur-xl bg-white/50 dark:bg-slate-800/50 border border-white/20 dark:border-slate-700/20">
|
||||
<div className="text-2xl font-bold text-primary">{resources.reduce((a, b) => a + b.downloads, 0).toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Downloads</div>
|
||||
</div>
|
||||
<div className="card p-4 backdrop-blur-xl bg-white/50 dark:bg-slate-800/50 border border-white/20 dark:border-slate-700/20">
|
||||
<div className="text-2xl font-bold text-primary">{categories.length - 1}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Categories</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filter Section */}
|
||||
<section className="max-w-6xl mx-auto px-6 py-8">
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-6 py-2.5 rounded-full font-semibold text-sm transition-all duration-300 ${
|
||||
selectedCategory === cat
|
||||
? "bg-primary text-white shadow-lg shadow-primary/30"
|
||||
: "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{cat === "All" ? "📋 All" : cat === "Guide" ? "📖 Guides" : cat === "Template" ? "📝 Templates" : "🎬 Videos"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resources Grid */}
|
||||
<section className="max-w-6xl mx-auto px-6 pb-20">
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredResources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="group card p-6 border border-slate-200/60 dark:border-slate-700/60 hover:border-primary/50 hover:shadow-2xl transition-all duration-300 hover:-translate-y-1 flex flex-col"
|
||||
>
|
||||
{/* Icon and Badge */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="text-6xl">{resource.icon}</div>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-xs font-bold rounded-full">
|
||||
{resource.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2 group-hover:text-primary transition-colors">
|
||||
{resource.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 flex-grow">
|
||||
{resource.description}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 py-3 border-t border-slate-200 dark:border-slate-700 mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Downloads</div>
|
||||
<div className="text-lg font-bold text-slate-900 dark:text-white">
|
||||
{(resource.downloads / 1000).toFixed(1)}K
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1 h-10 bg-gradient-to-b from-primary to-secondary rounded-full"></div>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Category</div>
|
||||
<div className="text-lg font-bold text-slate-900 dark:text-white">{resource.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button */}
|
||||
<button
|
||||
className="w-full py-3 px-4 rounded-lg font-semibold text-sm transition-all duration-300 bg-gradient-to-r from-primary to-secondary text-white hover:shadow-lg hover:shadow-primary/30 active:scale-95"
|
||||
>
|
||||
⬇️ Access Resource
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredResources.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="text-6xl mb-4">📭</div>
|
||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">No resources found</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">Try selecting a different category</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
324
app/signin/page.tsx
Normal file
324
app/signin/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
interface OAuthConfig {
|
||||
google?: { enabled: boolean };
|
||||
github?: { enabled: boolean };
|
||||
facebook?: { enabled: boolean };
|
||||
discord?: { enabled: boolean };
|
||||
}
|
||||
|
||||
function SignInContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [providers, setProviders] = useState<OAuthConfig>({});
|
||||
const [oauthLoading, setOAuthLoading] = useState("");
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// Get redirect URL from query params or default to dashboard
|
||||
const redirectUrl = searchParams.get("redirect") || "/account/webinars";
|
||||
|
||||
// Check if user is already authenticated
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
|
||||
if (data.user) {
|
||||
// User is already authenticated, redirect to dashboard
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check auth:", err);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [redirectUrl]);
|
||||
|
||||
// Load OAuth provider configuration
|
||||
useEffect(() => {
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/public/app-setup");
|
||||
const data = await res.json();
|
||||
if (data.setup?.data?.oauth) {
|
||||
setProviders(data.setup.data.oauth);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load OAuth providers:", err);
|
||||
}
|
||||
};
|
||||
loadProviders();
|
||||
}, []);
|
||||
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.ok) {
|
||||
setMessage(data.message || "Sign in failed");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect based on user role or to specified redirect URL
|
||||
const userRole = data.user?.role;
|
||||
if (userRole === "ADMIN") {
|
||||
window.location.href = "/admin";
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessage(err?.message || "Sign in failed. Please try again.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthSignIn = async (provider: "google" | "github" | "facebook" | "discord") => {
|
||||
try {
|
||||
setOAuthLoading(provider);
|
||||
// Redirect to OAuth provider with callback URL
|
||||
window.location.href = `/api/auth/${provider}?redirect=${encodeURIComponent(redirectUrl)}`;
|
||||
} catch (err: any) {
|
||||
setMessage(`${provider} sign in failed. Please try again.`);
|
||||
setOAuthLoading("");
|
||||
}
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4 animate-spin">⏳</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-slate-950">
|
||||
<div className="flex w-full max-w-6xl rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Left Side - Form */}
|
||||
<div className="w-full lg:w-1/2 px-8 lg:px-12 py-12 lg:py-16 flex flex-col justify-center bg-white dark:bg-slate-900">
|
||||
<div className="max-w-md mx-auto w-full">
|
||||
{/* Logo/Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
👤 Sign In
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Welcome back! Sign in to access your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error/Success Message */}
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-lg font-semibold text-center mb-6 ${
|
||||
message.includes("error") || message.includes("failed")
|
||||
? "bg-red-500/15 text-red-600 dark:bg-red-950/30 dark:text-red-400 border border-red-200/50 dark:border-red-900/50"
|
||||
: "bg-emerald-500/15 text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-400 border border-emerald-200/50 dark:border-emerald-900/50"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email/Password Form */}
|
||||
<form onSubmit={handleSignIn} className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-xs text-primary hover:underline dark:text-blue-400"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 active:bg-blue-800 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-lg transition-all duration-200 text-lg"
|
||||
>
|
||||
{loading ? "⏳ Signing In..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* OAuth Divider */}
|
||||
{Object.values(providers).some((p) => p?.enabled) && (
|
||||
<>
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-slate-600"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-slate-900 text-gray-600 dark:text-gray-400 font-medium">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Buttons */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
{providers.google?.enabled && (
|
||||
<button
|
||||
onClick={() => handleOAuthSignIn("google")}
|
||||
disabled={!!oauthLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-gray-300 dark:border-slate-600 rounded-lg hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-950/20 dark:hover:border-blue-500 transition-all duration-200 disabled:opacity-60"
|
||||
title="Continue with Google"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Google</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{providers.github?.enabled && (
|
||||
<button
|
||||
onClick={() => handleOAuthSignIn("github")}
|
||||
disabled={!!oauthLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-gray-300 dark:border-slate-600 rounded-lg hover:border-gray-900 dark:hover:border-white hover:bg-gray-50 dark:hover:bg-slate-800 transition-all duration-200 disabled:opacity-60"
|
||||
title="Continue with GitHub"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">GitHub</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{providers.facebook?.enabled && (
|
||||
<button
|
||||
onClick={() => handleOAuthSignIn("facebook")}
|
||||
disabled={!!oauthLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-gray-300 dark:border-slate-600 rounded-lg hover:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950/20 dark:hover:border-blue-600 transition-all duration-200 disabled:opacity-60"
|
||||
title="Continue with Facebook"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" fill="#1877F2" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Facebook</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{providers.discord?.enabled && (
|
||||
<button
|
||||
onClick={() => handleOAuthSignIn("discord")}
|
||||
disabled={!!oauthLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-gray-300 dark:border-slate-600 rounded-lg hover:border-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/20 dark:hover:border-indigo-600 transition-all duration-200 disabled:opacity-60"
|
||||
title="Continue with Discord"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path d="M20.317 4.3671a19.8062 19.8062 0 00-4.8851-1.5152.074.074 0 00-.0786.0371c-.211.3667-.4385.8453-.6005 1.2242-.5792-.0869-1.159-.0869-1.7335 0-.1624-.3928-.3957-.8575-.6021-1.2242a.077.077 0 00-.0785-.037 19.7355 19.7355 0 00-4.8852 1.515.0699.0699 0 00-.032.0274C.533 9.046-.32 13.58.0992 18.057a.082.082 0 00.031.0605c1.3143 1.0025 2.5871 1.6095 3.8343 2.0057a.0771.0771 0 00.084-.0271c.46-.6137.87-1.2646 1.225-1.9475a.077.077 0 00-.0422-.1062 12.906 12.906 0 01-1.838-.878.0771.0771 0 00-.008-.1277c.123-.092.246-.189.365-.276a.073.073 0 01.076-.01 19.896 19.896 0 0017.152 0 .073.073 0 01.076.01c.119.087.242.184.365.276a.077.077 0 00-.009.1277 12.823 12.823 0 01-1.838.878.0768.0768 0 00-.042.1062c.356.727.765 1.382 1.225 1.9475a.076.076 0 00.084.027 19.858 19.858 0 003.8343-2.0057.0822.0822 0 00.032-.0605c.464-4.547-.775-8.522-3.282-12.037a.0703.0703 0 00-.031-.0274zM8.02 15.3312c-1.1825 0-2.1569-.9718-2.1569-2.1575 0-1.1918.9556-2.1575 2.1569-2.1575 1.2108 0 2.1757.9718 2.1568 2.1575 0 1.1857-.9556 2.1575-2.1568 2.1575zm7.9605 0c-1.1825 0-2.1569-.9718-2.1569-2.1575 0-1.1918.9556-2.1575 2.1569-2.1575 1.2108 0 2.1757.9718 2.1568 2.1575 0 1.1857-.946 2.1575-2.1568 2.1575z" fill="#5865F2" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Discord</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<p className="text-center text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/signup" className="text-primary hover:underline font-semibold dark:text-blue-400">
|
||||
Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Background Image */}
|
||||
<div className="hidden lg:block lg:w-1/2 bg-gradient-to-br from-blue-400 via-blue-500 to-purple-600 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-40">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-white/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-blue-300/20 rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative h-full flex flex-col items-center justify-center p-12 text-white text-center">
|
||||
<div className="text-6xl mb-6">🚀</div>
|
||||
<h2 className="text-4xl font-bold mb-4">Welcome Back</h2>
|
||||
<p className="text-lg text-blue-50">
|
||||
Access your account and explore amazing features designed for your success.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin text-4xl mb-4">⏳</div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<SignInContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
403
app/signup/page.tsx
Normal file
403
app/signup/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
interface OAuthConfig {
|
||||
google?: { enabled: boolean };
|
||||
github?: { enabled: boolean };
|
||||
facebook?: { enabled: boolean };
|
||||
discord?: { enabled: boolean };
|
||||
}
|
||||
|
||||
function SignUpContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [passwordStrength, setPasswordStrength] = useState<{
|
||||
met: boolean;
|
||||
missing: string[];
|
||||
}>({ met: false, missing: [] });
|
||||
const [providers, setProviders] = useState<OAuthConfig>({});
|
||||
const [oauthLoading, setOAuthLoading] = useState("");
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// Get redirect URL from query params or default to dashboard
|
||||
const redirectUrl = searchParams.get("redirect") || "/account/webinars";
|
||||
|
||||
// Check if user is already authenticated
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
|
||||
if (data.user) {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check auth:", err);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [redirectUrl]);
|
||||
|
||||
// Load OAuth provider configuration
|
||||
useEffect(() => {
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/public/app-setup");
|
||||
const data = await res.json();
|
||||
if (data.setup?.data?.oauth) {
|
||||
setProviders(data.setup.data.oauth);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load OAuth providers:", err);
|
||||
}
|
||||
};
|
||||
loadProviders();
|
||||
}, []);
|
||||
|
||||
// Validate password strength
|
||||
useEffect(() => {
|
||||
const missing: string[] = [];
|
||||
if (password.length < 8 || password.length > 20) missing.push("8-20 characters");
|
||||
if (!/[A-Z]/.test(password)) missing.push("uppercase letter");
|
||||
if (!/\d/.test(password)) missing.push("number");
|
||||
if (/\s/.test(password)) missing.push("no spaces");
|
||||
setPasswordStrength({ met: missing.length === 0, missing });
|
||||
}, [password]);
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
if (!firstName.trim() || !lastName.trim()) {
|
||||
setMessage("First and last name are required");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setMessage("Passwords do not match");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordStrength.met) {
|
||||
setMessage(`Password must contain: ${passwordStrength.missing.join(", ")}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.ok) {
|
||||
setMessage(data.message || "Sign up failed");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to dashboard after successful signup
|
||||
setMessage("✅ Account created! Redirecting...");
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectUrl;
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setMessage(err?.message || "Sign up failed. Please try again.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthSignUp = async (provider: "google" | "github" | "facebook" | "discord") => {
|
||||
try {
|
||||
setOAuthLoading(provider);
|
||||
window.location.href = `/api/auth/${provider}?redirect=${encodeURIComponent(redirectUrl)}`;
|
||||
} catch (err: any) {
|
||||
setMessage(`${provider} sign up failed. Please try again.`);
|
||||
setOAuthLoading("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Loading State */}
|
||||
{checking && (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4 animate-spin">⏳</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
{!checking && (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-slate-950 py-12">
|
||||
<div className="flex w-full max-w-6xl rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Left Side - Form */}
|
||||
<div className="w-full lg:w-1/2 px-8 lg:px-12 py-12 lg:py-16 flex flex-col justify-center bg-white dark:bg-slate-900">
|
||||
<div className="max-w-md mx-auto w-full">
|
||||
{/* Logo/Header */}
|
||||
<div className="mb-8">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Start your journey</p>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
🚀 Sign Up
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Create an account and join our community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error/Success Message */}
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-lg font-semibold text-center mb-6 ${
|
||||
message.includes("error") || message.includes("failed") || message.includes("must") || message.includes("not")
|
||||
? "bg-red-500/15 text-red-600 dark:bg-red-950/30 dark:text-red-400 border border-red-200/50 dark:border-red-900/50"
|
||||
: "bg-emerald-500/15 text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-400 border border-emerald-200/50 dark:border-emerald-900/50"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sign Up Form */}
|
||||
<form onSubmit={handleSignUp} className="space-y-4 mb-6">
|
||||
<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">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="John"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
{password && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-gray-100 dark:bg-slate-800">
|
||||
<p className={`text-xs font-medium ${passwordStrength.met ? "text-emerald-600 dark:text-emerald-400" : "text-amber-600 dark:text-amber-400"}`}>
|
||||
{passwordStrength.met ? "✅ Password is strong" : `❌ Missing: ${passwordStrength.missing.join(", ")}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 active:bg-blue-800 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-lg transition-all duration-200 text-lg"
|
||||
>
|
||||
{loading ? "⏳ Creating Account..." : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* OAuth Divider */}
|
||||
{Object.values(providers).some((p) => p?.enabled) && (
|
||||
<>
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-slate-600"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-slate-900 text-gray-600 dark:text-gray-400 font-medium">
|
||||
Or sign up with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Buttons */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
{providers.google?.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthSignUp("google")}
|
||||
disabled={!!oauthLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-gray-300 dark:border-slate-600 rounded-lg hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-950/20 dark:hover:border-blue-500 transition-all duration-200 disabled:opacity-60"
|
||||
title="Continue with Google"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Google</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{providers.github?.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthSignUp("github")}
|
||||
disabled={!!oauthLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-gray-300 dark:border-slate-600 rounded-lg hover:border-gray-900 dark:hover:border-white hover:bg-gray-50 dark:hover:bg-slate-800 transition-all duration-200 disabled:opacity-60"
|
||||
title="Continue with GitHub"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" fill="#333" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">GitHub</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{providers.facebook?.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthSignUp("facebook")}
|
||||
disabled={!!oauthLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-gray-300 dark:border-slate-600 rounded-lg hover:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950/20 dark:hover:border-blue-600 transition-all duration-200 disabled:opacity-60"
|
||||
title="Continue with Facebook"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="#1877F2">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Facebook</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{providers.discord?.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthSignUp("discord")}
|
||||
disabled={!!oauthLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-gray-300 dark:border-slate-600 rounded-lg hover:border-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/20 dark:hover:border-indigo-600 transition-all duration-200 disabled:opacity-60"
|
||||
title="Continue with Discord"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M20.317 4.3671a19.8062 19.8062 0 00-4.8851-1.5152.074.074 0 00-.0786.0371c-.211.3667-.4385.8453-.6005 1.2242-.5792-.0869-1.159-.0869-1.7335 0-.1624-.3928-.3957-.8575-.6021-1.2242a.077.077 0 00-.0785-.037 19.7355 19.7355 0 00-4.8852 1.515.0699.0699 0 00-.032.0274C.533 9.046-.32 13.58.0992 18.057a.082.082 0 00.031.0605c1.3143 1.0025 2.5871 1.6095 3.8343 2.0057a.0771.0771 0 00.084-.0271c.46-.6137.87-1.2646 1.225-1.9475a.077.077 0 00-.0422-.1062 12.906 12.906 0 01-1.838-.878.0771.0771 0 00-.008-.1277c.123-.092.246-.189.365-.276a.073.073 0 01.076-.01 19.896 19.896 0 0017.152 0 .073.073 0 01.076.01c.119.087.242.184.365.276a.077.077 0 00-.009.1277 12.823 12.823 0 01-1.838.878.0768.0768 0 00-.042.1062c.356.727.765 1.382 1.225 1.9475a.076.076 0 00.084.027 19.858 19.858 0 003.8343-2.0057.0822.0822 0 00.032-.0605c.464-4.547-.775-8.522-3.282-12.037a.0703.0703 0 00-.031-.0274zM8.02 15.3312c-1.1825 0-2.1569-.9718-2.1569-2.1575 0-1.1918.9556-2.1575 2.1569-2.1575 1.2108 0 2.1757.9718 2.1568 2.1575 0 1.1857-.9556 2.1575-2.1568 2.1575zm7.9605 0c-1.1825 0-2.1569-.9718-2.1569-2.1575 0-1.1918.9556-2.1575 2.1569-2.1575 1.2108 0 2.1757.9718 2.1568 2.1575 0 1.1857-.946 2.1575-2.1568 2.1575z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Discord</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sign In Link */}
|
||||
<p className="text-center text-gray-600 dark:text-gray-400">
|
||||
Already have an account?{" "}
|
||||
<Link href="/signin" className="text-primary hover:underline font-semibold dark:text-blue-400">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Background Image */}
|
||||
<div className="hidden lg:block lg:w-1/2 bg-gradient-to-br from-cyan-300 via-blue-500 to-purple-600 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-40">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-white/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-cyan-300/20 rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative h-full flex flex-col items-center justify-center p-12 text-white text-center">
|
||||
<div className="text-6xl mb-6">🌟</div>
|
||||
<h2 className="text-4xl font-bold mb-4">Join the Community</h2>
|
||||
<p className="text-lg text-blue-50">
|
||||
Sign up today and unlock access to exclusive features and content.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin text-4xl mb-4">⏳</div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<SignUpContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
266
app/webinars/[id]/WebinarDetailClient.tsx
Normal file
266
app/webinars/[id]/WebinarDetailClient.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Webinar {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
speaker: string;
|
||||
startAt: string;
|
||||
duration: number;
|
||||
priceCents: number;
|
||||
visibility: "PUBLIC" | "PRIVATE";
|
||||
isActive: boolean;
|
||||
capacity: number;
|
||||
learningPoints?: string[];
|
||||
_count?: { registrations: number };
|
||||
}
|
||||
|
||||
interface WebinarDetailClientProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default function WebinarDetailClient({ id }: WebinarDetailClientProps) {
|
||||
const [webinar, setWebinar] = useState<Webinar | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
fetchWebinar();
|
||||
checkAuth();
|
||||
}, [id]);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me");
|
||||
setIsAuthenticated(response.ok);
|
||||
} catch (error) {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWebinar = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/webinars/${id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setWebinar(data.webinar);
|
||||
} else {
|
||||
router.push("/webinars");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch webinar:", error);
|
||||
router.push("/webinars");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!isAuthenticated) {
|
||||
setShowAuthModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setRegistering(true);
|
||||
try {
|
||||
const response = await fetch("/api/registrations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ webinarId: id }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert("Successfully registered for the webinar!");
|
||||
router.push("/account/webinars");
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(data.error || "Registration failed");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Failed to register. Please try again.");
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900" />
|
||||
<div className="absolute -top-24 right-0 h-72 w-72 rounded-full bg-primary/20 blur-3xl" />
|
||||
<div className="absolute -bottom-24 left-0 h-72 w-72 rounded-full bg-secondary/20 blur-3xl" />
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-6 py-16 lg:py-20">
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 backdrop-blur-md p-10 shadow-lg text-center">
|
||||
<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 webinar details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!webinar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const webinarDate = new Date(webinar.startAt);
|
||||
const registeredCount = webinar._count?.registrations || 0;
|
||||
const spotsLeft = webinar.capacity - registeredCount;
|
||||
const isFull = spotsLeft <= 0;
|
||||
|
||||
return (
|
||||
<main className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900" />
|
||||
<div className="absolute -top-24 right-0 h-72 w-72 rounded-full bg-primary/20 blur-3xl" />
|
||||
<div className="absolute -bottom-24 left-0 h-72 w-72 rounded-full bg-secondary/20 blur-3xl" />
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-6 py-16 lg:py-20">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="mb-8 inline-flex items-center gap-2 text-sm font-semibold text-gray-600 dark:text-gray-400 hover:text-primary transition-colors"
|
||||
>
|
||||
← Back to Webinars
|
||||
</button>
|
||||
|
||||
<div className="rounded-3xl border border-gray-200/70 dark:border-slate-800/70 bg-white/90 dark:bg-slate-900/80 backdrop-blur-md shadow-[0_24px_60px_rgba(15,23,42,0.15)] overflow-hidden">
|
||||
<div className="relative px-8 py-10 bg-gradient-to-r from-primary/90 via-primary to-secondary text-white">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-6 right-10 h-24 w-24 rounded-full bg-white/30 blur-2xl" />
|
||||
<div className="absolute bottom-6 left-10 h-28 w-28 rounded-full bg-white/30 blur-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-wrap items-center gap-3 mb-5">
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-bold ${webinar.isActive ? "bg-white/20 text-white" : "bg-white/10 text-white/70"}`}>
|
||||
{webinar.isActive ? "ACTIVE" : "INACTIVE"}
|
||||
</span>
|
||||
{isFull && (
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-red-500/90 text-white">
|
||||
FULL
|
||||
</span>
|
||||
)}
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-white/15 text-white">
|
||||
{webinar.visibility === "PRIVATE" ? "PRIVATE" : "PUBLIC"}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-black mb-4">{webinar.title}</h1>
|
||||
<p className="text-lg text-white/90 max-w-3xl">{webinar.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 lg:p-10 grid lg:grid-cols-[1.4fr_0.9fr] gap-8">
|
||||
<div>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-slate-50/70 dark:bg-slate-900/60 p-6">
|
||||
<p className="text-sm font-semibold text-gray-500 dark:text-gray-400">📅 Date & Time</p>
|
||||
<p className="mt-2 text-lg font-bold text-gray-900 dark:text-white">
|
||||
{webinarDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{webinarDate.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-slate-50/70 dark:bg-slate-900/60 p-6">
|
||||
<p className="text-sm font-semibold text-gray-500 dark:text-gray-400">👨🏫 Instructor</p>
|
||||
<p className="mt-2 text-lg font-bold text-gray-900 dark:text-white">
|
||||
{webinar.speaker}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Estate planning specialist</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-slate-50/70 dark:bg-slate-900/60 p-6">
|
||||
<p className="text-sm font-semibold text-gray-500 dark:text-gray-400">⏱️ Duration</p>
|
||||
<p className="mt-2 text-lg font-bold text-gray-900 dark:text-white">{webinar.duration} minutes</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Interactive Q&A included</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-slate-50/70 dark:bg-slate-900/60 p-6">
|
||||
<p className="text-sm font-semibold text-gray-500 dark:text-gray-400">💰 Price</p>
|
||||
<p className="mt-2 text-3xl font-black text-primary">
|
||||
{webinar.priceCents === 0 ? "FREE" : `$${(webinar.priceCents / 100).toFixed(2)}`}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Secure checkout</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webinar.learningPoints && webinar.learningPoints.length > 0 && (
|
||||
<div className="mt-8 rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">📚 What You'll Learn</h3>
|
||||
<ul className="grid sm:grid-cols-2 gap-3">
|
||||
{webinar.learningPoints.map((point, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span className="text-primary font-bold">•</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 p-6 shadow-lg">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-3">Registration</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isFull
|
||||
? "This webinar is fully booked."
|
||||
: `${spotsLeft} spots left out of ${webinar.capacity}`}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
disabled={registering || isFull}
|
||||
className={`w-full py-3 rounded-xl text-sm font-semibold transition-colors ${
|
||||
isFull
|
||||
? "bg-gray-200 text-gray-500 cursor-not-allowed dark:bg-slate-800 dark:text-slate-500"
|
||||
: "bg-primary text-white hover:bg-primary/90"
|
||||
}`}
|
||||
>
|
||||
{registering ? "Registering..." : isFull ? "Fully Booked" : "Register Now"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAuthModal && (
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/90 dark:bg-slate-900/80 p-6 shadow-lg">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Sign in required</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Please sign in to register for this webinar.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/signin?redirect=/webinars/" + id)}
|
||||
className="flex-1 py-2 rounded-lg bg-primary text-white text-sm font-semibold"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAuthModal(false)}
|
||||
className="flex-1 py-2 rounded-lg border border-gray-300 dark:border-slate-700 text-sm font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
12
app/webinars/[id]/page.tsx
Normal file
12
app/webinars/[id]/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import WebinarDetailClient from "./WebinarDetailClient";
|
||||
|
||||
interface WebinarDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function WebinarDetailPage({
|
||||
params,
|
||||
}: WebinarDetailPageProps) {
|
||||
const { id } = await params;
|
||||
return <WebinarDetailClient id={id} />;
|
||||
}
|
||||
264
app/webinars/page.tsx
Normal file
264
app/webinars/page.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Webinar {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
speaker: string;
|
||||
startAt: string;
|
||||
duration: number;
|
||||
bannerUrl?: string;
|
||||
category: string;
|
||||
capacity: number;
|
||||
priceCents: number;
|
||||
}
|
||||
|
||||
interface RegistrationItem {
|
||||
webinarId: string;
|
||||
registeredAt: string;
|
||||
webinar: {
|
||||
startAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function WebinarsPage() {
|
||||
const [webinars, setWebinars] = useState<Webinar[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [registeredMap, setRegisteredMap] = useState<Record<string, { registeredAt: string; startAt: string }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchWebinars() {
|
||||
try {
|
||||
const [webinarsRes, registrationsRes] = await Promise.all([
|
||||
fetch("/api/webinars?limit=50"),
|
||||
fetch("/api/account/webinars"),
|
||||
]);
|
||||
|
||||
const webinarsData = await webinarsRes.json();
|
||||
setWebinars(webinarsData.webinars || []);
|
||||
|
||||
if (registrationsRes.ok) {
|
||||
const registrationsData = await registrationsRes.json();
|
||||
const nextMap: Record<string, { registeredAt: string; startAt: string }> = {};
|
||||
|
||||
(registrationsData.registrations || []).forEach((reg: RegistrationItem) => {
|
||||
nextMap[reg.webinarId] = {
|
||||
registeredAt: reg.registeredAt,
|
||||
startAt: reg.webinar?.startAt,
|
||||
};
|
||||
});
|
||||
|
||||
setRegisteredMap(nextMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch webinars:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchWebinars();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900" />
|
||||
<div className="absolute -top-24 right-0 h-72 w-72 rounded-full bg-primary/20 blur-3xl" />
|
||||
<div className="absolute -bottom-24 left-0 h-72 w-72 rounded-full bg-secondary/20 blur-3xl" />
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-6 py-16 lg:py-20">
|
||||
<div className="text-center mb-14">
|
||||
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.2em] uppercase text-primary/80 bg-primary/10 px-4 py-2 rounded-full">
|
||||
Estate Planning Academy
|
||||
</p>
|
||||
<h1 className="mt-5 text-4xl md:text-5xl lg:text-6xl font-black text-gray-900 dark:text-white">
|
||||
Professional Webinars, Clear Outcomes
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-gray-600 dark:text-gray-400 max-w-3xl mx-auto">
|
||||
Curated sessions built by attorneys and planners to help you protect wealth, reduce tax exposure, and plan with confidence.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-10">
|
||||
{[
|
||||
{ label: "Expert Sessions", value: "40+" },
|
||||
{ label: "Live Each Month", value: "8" },
|
||||
{ label: "Average Rating", value: "4.9" },
|
||||
].map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 backdrop-blur-md p-6 text-center shadow-lg"
|
||||
>
|
||||
<div className="text-3xl font-black text-gray-900 dark:text-white">{stat.value}</div>
|
||||
<p className="mt-2 text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{stat.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 backdrop-blur-md p-10 shadow-lg text-center text-gray-600 dark:text-gray-400">
|
||||
Loading webinars...
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900" />
|
||||
<div className="absolute -top-24 right-0 h-72 w-72 rounded-full bg-primary/20 blur-3xl" />
|
||||
<div className="absolute -bottom-24 left-0 h-72 w-72 rounded-full bg-secondary/20 blur-3xl" />
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-6 py-16 lg:py-20">
|
||||
<div className="grid lg:grid-cols-[1.3fr_0.7fr] gap-8 items-end mb-12">
|
||||
<div>
|
||||
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.2em] uppercase text-primary/80 bg-primary/10 px-4 py-2 rounded-full">
|
||||
Estate Planning Academy
|
||||
</p>
|
||||
<h1 className="mt-5 text-4xl md:text-5xl lg:text-6xl font-black text-gray-900 dark:text-white">
|
||||
Professional Webinars, Clear Outcomes
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-gray-600 dark:text-gray-400 max-w-2xl">
|
||||
Curated sessions built by attorneys and planners to help you protect wealth, reduce tax exposure, and plan with confidence.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 backdrop-blur-md p-6 shadow-lg">
|
||||
<p className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Quick stats</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>Expert Sessions</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">40+</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>Live Each Month</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">8</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>Average Rating</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">4.9</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webinars.length === 0 ? (
|
||||
<div className="rounded-2xl border border-gray-200/70 dark:border-slate-800/70 bg-white/80 dark:bg-slate-900/70 backdrop-blur-md p-10 shadow-lg text-center text-gray-600 dark:text-gray-400">
|
||||
No webinars available at the moment.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-7">
|
||||
{webinars.map((webinar) => {
|
||||
const registration = registeredMap[webinar.id];
|
||||
const isRegistered = Boolean(registration);
|
||||
const startAt = new Date(webinar.startAt);
|
||||
const isPast = startAt.getTime() < Date.now();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={webinar.id}
|
||||
className="group rounded-3xl border border-gray-200/70 dark:border-slate-800/70 bg-white/90 dark:bg-slate-900/80 backdrop-blur-md overflow-hidden shadow-[0_20px_50px_rgba(15,23,42,0.12)] hover:shadow-[0_24px_60px_rgba(15,23,42,0.18)] transition-all duration-300"
|
||||
>
|
||||
<div className="relative h-44 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/25 via-primary/5 to-secondary/25" />
|
||||
{webinar.bannerUrl ? (
|
||||
<img
|
||||
src={webinar.bannerUrl}
|
||||
alt={webinar.title}
|
||||
className="relative w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-full w-full flex items-center justify-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Webinar preview
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-4 left-4 flex flex-wrap gap-2">
|
||||
<span className="rounded-full bg-white/90 text-primary text-xs font-semibold px-3 py-1 shadow">
|
||||
{webinar.category}
|
||||
</span>
|
||||
{webinar.priceCents === 0 ? (
|
||||
<span className="rounded-full bg-emerald-500/90 text-white text-xs font-semibold px-3 py-1 shadow">
|
||||
Free
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isRegistered && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<span className={`rounded-full text-xs font-semibold px-3 py-1 shadow ${isPast ? "bg-slate-700 text-white" : "bg-emerald-500 text-white"}`}>
|
||||
{isPast ? "Completed" : "Registered"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{webinar.title}
|
||||
</h3>
|
||||
{webinar.priceCents > 0 && (
|
||||
<span className="text-sm font-bold text-primary dark:text-secondary">
|
||||
${(webinar.priceCents / 100).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 line-clamp-2">
|
||||
{webinar.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span>🎤</span>
|
||||
<span className="font-medium">{webinar.speaker}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span>📅</span>
|
||||
<span>{formatDate(webinar.startAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span>⏱️</span>
|
||||
<span>{webinar.duration} min</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span>👥</span>
|
||||
<span>{webinar.capacity} seats</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<span className={`text-xs font-semibold px-3 py-1 rounded-full ${isPast ? "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300" : "bg-primary/10 text-primary"}`}>
|
||||
{isPast ? "Past session" : "Upcoming"}
|
||||
</span>
|
||||
<a
|
||||
href={`/webinars/${webinar.id}`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-primary to-primary-dark shadow-md hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
View details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user