Initial commit

This commit is contained in:
Developer
2026-02-06 21:44:04 -06:00
commit f85e93c7a6
151 changed files with 22916 additions and 0 deletions

View 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>
);
}

View 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
View 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>
);
}