Skip to content
18 changes: 18 additions & 0 deletions app/badges/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getAuthUser } from "@/lib/auth";
import AdminBadgesPage from "@/components/badges/AdminBadge";
import UserBadgesPage from "@/components/badges/UserBadge";

export default async function BadgesPage() {
const user = await getAuthUser();
if (!user || !user.hasCompletedOnboarding) {
return null;
}

const isAdmin = user?.organisation?.role === "admin";

if (isAdmin) {
return null;
}

return <UserBadgesPage />;
}
14 changes: 13 additions & 1 deletion app/organisation/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import ManageChannels from "@/components/organisation/settings/ManageChannels";
import ManageLevels from "@/components/organisation/settings/ManageLevels";
import OnboardingConfig from "@/components/organisation/settings/OnboardingConfig";
import OrgSettings from "@/components/organisation/settings/OrgSettings";
import AdminBadgesPage from "@/components/badges/AdminBadge";

export default function OrganisationsPage() {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<
"skills" | "channels" | "levels" | "onboarding" | "orgSettings"
"skills" | "channels" | "levels" | "onboarding" | "orgSettings" | "badges"
>("skills");

if (!user || !user.hasCompletedOnboarding) {
Expand Down Expand Up @@ -79,6 +80,16 @@ export default function OrganisationsPage() {
>
Onboarding Form
</button>
<button
onClick={() => setActiveTab("badges")}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === "badges"
? "border-purple-500 text-purple-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Manage Badges
</button>
<button
onClick={() => setActiveTab("orgSettings")}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
Expand All @@ -99,6 +110,7 @@ export default function OrganisationsPage() {
{activeTab === "levels" && <ManageLevels />}
{activeTab === "onboarding" && <OnboardingConfig />}
{activeTab === "orgSettings" && <OrgSettings />}
{activeTab === "badges" && <AdminBadgesPage />}
</div>
);
}
2 changes: 2 additions & 0 deletions components/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
MapIcon,
ClockIcon,
CogIcon,
StarIcon,
} from "@heroicons/react/24/outline";
import LogoutButton from "./auth/logout/LogoutButton";
import { useAuth } from "@/context/AuthContext";
Expand All @@ -23,6 +24,7 @@ const menuSections = [
{ label: "My Roadmap", href: "/roadmap", icon: MapIcon },
{ label: "Courses", href: "/courses", icon: BookOpenIcon },
{ label: "Reports", href: "/reports", icon: ChartBarIcon },
{ label: "Badges", href: "/badges", icon: StarIcon },
],
},
{
Expand Down
317 changes: 317 additions & 0 deletions components/badges/AdminBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
"use client";

import Link from "next/link";
import { useState, useEffect } from "react";

interface FrequentBadge {
id: number;
name: string;
description: string;
numCoursesCompleted: number;
}

interface CourseBadge {
id: number;
name: string;
description: string;
courseId: number;
}

interface CreatedBadges {
coursesBadges: FrequentBadge[];
courseBadges: CourseBadge[];
}

interface Course {
id: number;
name: string;
}

export default function AdminBadgesPage() {
const [badges, setBadges] = useState<CreatedBadges>({
coursesBadges: [],
courseBadges: [],
});
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const [freqName, setFreqName] = useState("");
const [freqDesc, setFreqDesc] = useState("");
const [freqCount, setFreqCount] = useState<number | "">("");

const [specName, setSpecName] = useState("");
const [specDesc, setSpecDesc] = useState("");
const [specCourseId, setSpecCourseId] = useState<number | "">("");

useEffect(() => {
fetchBadges();
fetchCourses();
}, []);

const fetchBadges = () => {
setLoading(true);
fetch("/api/badges/created-badges", { credentials: "include" })
.then((r) => {
if (!r.ok) throw new Error("Failed to load badges");
return r.json() as Promise<CreatedBadges>;
})
.then((data) => {
setBadges(data);
setError(null);
})
.catch((err) => {
console.error(err);
setError(err.message);
})
.finally(() => setLoading(false));
};

const fetchCourses = async () => {
try {
const res = await fetch("/api/courses", { credentials: "include" });
if (!res.ok) throw new Error("Failed to load courses");
const data = (await res.json()) as { courses: Course[] };
setCourses(data.courses);
} catch (err) {
console.error(err);
}
};

const addFrequentBadge = async () => {
if (!freqName.trim() || freqCount === "" || freqCount < 0) {
alert("Name and non-negative course count are required");
return;
}
const res = await fetch("/api/badges/create-frequent", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: freqName.trim(),
description: freqDesc.trim(),
numCoursesCompleted: +freqCount,
}),
});
if (res.ok) {
setFreqName("");
setFreqDesc("");
setFreqCount("");
fetchBadges();
} else {
alert("Could not create badge");
}
};

const addCourseBadge = async () => {
if (!specName.trim() || specCourseId === "") {
alert("Name and course selection are required");
return;
}
const res = await fetch("/api/badges/create-specific-course", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: specName.trim(),
description: specDesc.trim(),
courseId: specCourseId,
}),
});
if (res.ok) {
setSpecName("");
setSpecDesc("");
setSpecCourseId("");
fetchBadges();
} else {
alert("Could not create badge");
}
};

const deleteFrequentBadge = async (id: number) => {
if (!confirm("Delete this badge?")) return;
const res = await fetch("/api/badges/frequent-badge", {
method: "DELETE",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ badgeId: id }),
});
if (res.ok) fetchBadges();
else alert("Failed to delete");
};

const deleteCourseBadge = async (id: number) => {
if (!confirm("Delete this badge?")) return;
const res = await fetch("/api/badges/course-specific-badge", {
method: "DELETE",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ badgeId: id }),
});
if (res.ok) fetchBadges();
else alert("Failed to delete");
};

return (
<div className="p-6 space-y-8">
<h1 className="text-3xl font-bold text-purple-600">Manage Badges</h1>
{error && (
<div className="text-red-600 bg-red-100 p-3 rounded">{error}</div>
)}

<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-4 border rounded-lg bg-white shadow-sm">
<h2 className="text-xl font-semibold mb-2">
Add “Complete X Courses” Badge
</h2>
<input
type="text"
placeholder="Badge Name"
value={freqName}
onChange={(e) => setFreqName(e.target.value)}
className="w-full p-2 border rounded mb-2"
/>
<textarea
placeholder="Description (optional)"
value={freqDesc}
onChange={(e) => setFreqDesc(e.target.value)}
className="w-full p-2 border rounded mb-2"
rows={2}
/>
<input
type="number"
min={0}
placeholder="Number of courses"
value={freqCount}
onChange={(e) =>
setFreqCount(e.target.value === "" ? "" : +e.target.value)
}
className="w-full p-2 border rounded mb-4"
/>
<button
onClick={addFrequentBadge}
className="px-4 py-2 bg-green-600 text-white rounded"
>
Create Badge
</button>
</div>

<div className="p-4 border rounded-lg bg-white shadow-sm">
<h2 className="text-xl font-semibold mb-2">
Add “Course-Specific” Badge
</h2>
<input
type="text"
placeholder="Badge Name"
value={specName}
onChange={(e) => setSpecName(e.target.value)}
className="w-full p-2 border rounded mb-2"
/>
<textarea
placeholder="Description (optional)"
value={specDesc}
onChange={(e) => setSpecDesc(e.target.value)}
className="w-full p-2 border rounded mb-2"
rows={2}
/>
<select
value={specCourseId}
onChange={(e) => setSpecCourseId(Number(e.target.value) || "")}
className="w-full p-2 border rounded mb-4"
>
<option value="">Select a course…</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button
onClick={addCourseBadge}
className="px-4 py-2 bg-green-600 text-white rounded"
>
Create Badge
</button>
</div>
</div>

<section>
<h2 className="text-2xl font-semibold mb-4">Existing Badges</h2>

<h3 className="text-lg font-medium mb-2">Complete X Courses</h3>
{loading ? (
<p>Loading…</p>
) : badges.coursesBadges.length === 0 ? (
<p className="text-gray-500">No frequent badges created.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{badges.coursesBadges.map((b) => (
<div
key={b.id}
className="p-3 border rounded-lg bg-purple-50 flex flex-col"
>
<div className="flex justify-between items-start">
<span className="font-semibold">{b.name}</span>
<button
onClick={() => deleteFrequentBadge(b.id)}
className="text-red-600"
>
×
</button>
</div>
<small className="italic text-sm">
when you complete {b.numCoursesCompleted} courses
</small>
{b.description && (
<p className="text-gray-700 mt-1 line-clamp-2">
{b.description}
</p>
)}
</div>
))}
</div>
)}

<h3 className="text-lg font-medium mt-6 mb-2">Course-Specific</h3>
{loading ? (
<p>Loading…</p>
) : badges.courseBadges.length === 0 ? (
<p className="text-gray-500">No course-specific badges created.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{badges.courseBadges.map((b) => (
<div
key={b.id}
className="p-3 border rounded-lg bg-purple-50 flex flex-col"
>
<div className="flex justify-between items-start">
<span className="font-semibold">{b.name}</span>
<button
onClick={() => deleteCourseBadge(b.id)}
className="text-red-600"
>
×
</button>
</div>
<small className="italic text-sm">
on completion of course #
<Link
href={`/courses/${b.courseId}`}
className="text-blue-600 underline hover:text-blue-800"
>
{b.name}
</Link>
</small>
{b.description && (
<p className="text-gray-700 mt-1 line-clamp-2">
{b.description}
</p>
)}
</div>
))}
</div>
)}
</section>
</div>
);
}
Loading