Initial commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user