diff --git a/app/dashboard/courses/categories/page.jsx b/app/dashboard/courses/categories/page.jsx
new file mode 100644
index 00000000..26c3d741
--- /dev/null
+++ b/app/dashboard/courses/categories/page.jsx
@@ -0,0 +1,180 @@
+"use client";
+import { useEffect, useState, useMemo } from "react";
+import Link from "next/link";
+import { fetchCourses } from "@/lib/actions/courses/fetch-courses";
+import CourseCardSkeleton from "@/components/atoms/skeletons/CourseCardSkeleton";
+import NetworkErrorComp from "@/components/molecules/errors/NetworkError";
+import {
+ CATEGORY_GROUPS,
+ CATEGORIES,
+ getCategoryCounts,
+} from "@/lib/categories";
+import { BookOpen, ArrowRight, LayoutGrid } from "lucide-react";
+
+export default function CategoryHubPage() {
+ const [courses, setCourses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ const loadCourses = async () => {
+ setLoading(true);
+ setError(false);
+ try {
+ const data = await fetchCourses();
+ setCourses(data);
+ } catch {
+ setError(true);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadCourses();
+ }, []);
+
+ // Derive counts from the fetched course list
+ const counts = useMemo(() => getCategoryCounts(courses), [courses]);
+
+ const totalCourses = courses.length;
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* ── Hero header ── */}
+
+
+
+
+
+ Course Categories
+
+
+
+ Browse authentic Islamic knowledge across{" "}
+ {CATEGORIES.length} categories grouped into{" "}
+ {CATEGORY_GROUPS.length} disciplines.
+
+ {!loading && (
+
+ {totalCourses} course{totalCourses !== 1 ? "s" : ""} available
+
+ )}
+
+
+
+ Browse All Courses
+
+
+
+
+
+ {/* ── Loading skeletons ── */}
+ {loading ? (
+
+ {[...Array(3)].map((_, gi) => (
+
+
+
+ {[...Array(3)].map((_, ci) => (
+
+ ))}
+
+
+ ))}
+
+ ) : (
+ /* ── Category groups ── */
+
+ {CATEGORY_GROUPS.map((group) => {
+ const groupCategories = CATEGORIES.filter(
+ (c) => c.group === group
+ );
+ return (
+
+
+ {group}
+
+
+ {groupCategories.map((cat) => {
+ const count = counts[cat.slug] || 0;
+ const isEmpty = count === 0;
+ return (
+
+ {/* Icon + count */}
+
+
+ {cat.icon}
+
+
+ {count} course{count !== 1 ? "s" : ""}
+
+
+
+ {/* Label + description */}
+
+ {cat.label}
+
+
+ {cat.description}
+
+
+ {/* CTA row */}
+
+ {isEmpty ? "No courses yet" : "View courses"}
+
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/app/dashboard/courses/category/[slug]/page.jsx b/app/dashboard/courses/category/[slug]/page.jsx
new file mode 100644
index 00000000..7bdc98a8
--- /dev/null
+++ b/app/dashboard/courses/category/[slug]/page.jsx
@@ -0,0 +1,247 @@
+"use client";
+import { useEffect, useState, useMemo } from "react";
+import { use } from "react";
+import Link from "next/link";
+import { fetchCourses } from "@/lib/actions/courses/fetch-courses";
+import CourseCard from "@/components/molecules/dashboard/cards/courseCard";
+import CourseCardSkeleton from "@/components/atoms/skeletons/CourseCardSkeleton";
+import NetworkErrorComp from "@/components/molecules/errors/NetworkError";
+import NotFoundComp from "@/components/molecules/errors/NotFound";
+import Modal from "@/components/molecules/Modal";
+import CreateCourseForm from "@/components/organisms/create/course-create-form";
+import { getCategoryBySlug, resolveSlug } from "@/lib/categories";
+import { getAverageRating } from "@/hooks/getAverageRating";
+import { ArrowLeft, BookOpen, Plus } from "lucide-react";
+
+// Sort options
+const SORT_OPTIONS = [
+ { value: "newest", label: "Newest" },
+ { value: "price-asc", label: "Price: Low to High" },
+ { value: "price-desc", label: "Price: High to Low" },
+ { value: "rating", label: "Top Rated" },
+];
+
+function sortCourses(courses, sort) {
+ const sorted = [...courses];
+ switch (sort) {
+ case "price-asc":
+ return sorted.sort((a, b) => (a.price || 0) - (b.price || 0));
+ case "price-desc":
+ return sorted.sort((a, b) => (b.price || 0) - (a.price || 0));
+ case "rating":
+ return sorted.sort(
+ (a, b) =>
+ getAverageRating(b.reviews || []) -
+ getAverageRating(a.reviews || [])
+ );
+ case "newest":
+ default:
+ return sorted.sort(
+ (a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
+ );
+ }
+}
+
+export default function CategoryLandingPage({ params }) {
+ // Unwrap params using React.use() for Next.js 15+
+ const { slug } = use(params);
+
+ const category = getCategoryBySlug(slug);
+
+ const [allCourses, setAllCourses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+ const [sort, setSort] = useState("newest");
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const loadCourses = async () => {
+ setLoading(true);
+ setError(false);
+ try {
+ const data = await fetchCourses();
+ setAllCourses(data);
+ } catch {
+ setError(true);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadCourses();
+ }, [slug]);
+
+ // Filter to this category's courses
+ const categoryCourses = useMemo(
+ () =>
+ sortCourses(
+ allCourses.filter((c) => resolveSlug(c.category) === slug),
+ sort
+ ),
+ [allCourses, slug, sort]
+ );
+
+ // Unknown slug → graceful not-found, no crash
+ if (!category) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+ {/* ── Hero header ── */}
+
+
+ {/* Breadcrumb */}
+
+
+
+ Courses
+
+ /
+
+ Categories
+
+ /
+ {category.label}
+
+
+ {/* Icon + title */}
+
+
+ {category.icon}
+
+
+
+ {category.label}
+
+
+ {category.description}
+
+ {!loading && (
+
+ {categoryCourses.length} course
+ {categoryCourses.length !== 1 ? "s" : ""}
+
+ )}
+
+
+
+ {/* Group badge */}
+
+
+ {category.group}
+
+
+
+
+
+ {/* ── Controls ── */}
+
+
+
+
+ {loading ? "Loading…" : `${categoryCourses.length} course${categoryCourses.length !== 1 ? "s" : ""}`}
+
+
+
+ {/* Sort */}
+
setSort(e.target.value)}
+ className="rounded-full border border-input bg-background px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
+ aria-label="Sort courses"
+ >
+ {SORT_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ {/* Create course CTA */}
+
setModalOpen(true)}
+ className="inline-flex items-center gap-1 rounded-full bg-accent text-white px-4 py-2 text-sm font-semibold hover:bg-accent/90 transition-colors"
+ >
+
+ Add Course
+
+
+
+
+ {/* ── Course grid ── */}
+
+ {loading ? (
+
+ {[...Array(6)].map((_, idx) => (
+
+ ))}
+
+ ) : categoryCourses.length === 0 ? (
+ /* ── Empty state ── */
+
+
+ {category.icon}
+
+
+ No courses yet in {category.label}
+
+
+ Be the first educator to share knowledge in this discipline. The
+ Ummah is waiting for you!
+
+
setModalOpen(true)}
+ className="inline-flex items-center gap-2 rounded-full bg-accent text-white px-6 py-3 font-semibold hover:bg-accent/90 transition-colors shadow"
+ >
+
+ Create the first course
+
+
+ Browse other categories
+
+
+ ) : (
+
+ {categoryCourses.map((course) => (
+
+ ))}
+
+ )}
+
+
+
+ {/* Create Course modal */}
+ setModalOpen(false)}
+ className="max-w-md w-full"
+ >
+
+
+ >
+ );
+}
diff --git a/app/dashboard/courses/page.jsx b/app/dashboard/courses/page.jsx
index 83b018b2..6bc7d4f6 100644
--- a/app/dashboard/courses/page.jsx
+++ b/app/dashboard/courses/page.jsx
@@ -1,5 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useCallback, useMemo } from "react";
+import { useRouter, useSearchParams, usePathname } from "next/navigation";
import CourseCard from "@/components/molecules/dashboard/cards/courseCard";
import CourseCardSkeleton from "@/components/atoms/skeletons/CourseCardSkeleton";
import Button from "@/components/atoms/form/Button";
@@ -9,36 +10,153 @@ import { fetchCourses } from "@/lib/actions/courses/fetch-courses";
import { getBookmarkedCourses } from "@/lib/actions/courses/bookmark-course";
import useAuth from "@/hooks/useAuth";
import NetworkErrorComp from "@/components/molecules/errors/NetworkError";
+import { CATEGORIES, resolveSlug } from "@/lib/categories";
+import { getAverageRating } from "@/hooks/getAverageRating";
+import { Search, X, LayoutGrid } from "lucide-react";
+import Link from "next/link";
+
+// Sort options
+const SORT_OPTIONS = [
+ { value: "newest", label: "Newest" },
+ { value: "price-asc", label: "Price: Low to High" },
+ { value: "price-desc", label: "Price: High to Low" },
+ { value: "rating", label: "Top Rated" },
+];
+
+function sortCourses(courses, sort) {
+ const sorted = [...courses];
+ switch (sort) {
+ case "price-asc":
+ return sorted.sort((a, b) => (a.price || 0) - (b.price || 0));
+ case "price-desc":
+ return sorted.sort((a, b) => (b.price || 0) - (a.price || 0));
+ case "rating":
+ return sorted.sort(
+ (a, b) =>
+ getAverageRating(b.reviews || []) - getAverageRating(a.reviews || [])
+ );
+ case "newest":
+ default:
+ return sorted.sort(
+ (a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
+ );
+ }
+}
export default function CoursesPage() {
const { user } = useAuth();
- const [courses, setCourses] = useState([]);
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ // URL-driven state
+ const urlCategory = searchParams.get("category") || "";
+ const urlSort = searchParams.get("sort") || "newest";
+ const urlSearch = searchParams.get("q") || "";
+
+ // Local filter state (mirrors URL, kept in sync)
+ const [search, setSearch] = useState(urlSearch);
+
+ const [allCourses, setAllCourses] = useState([]);
const [modalOpen, setModalOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [showBookmarks, setShowBookmarks] = useState(false);
- const fetchData = async () => {
+ // Keep local search in sync when URL changes (e.g. browser back/forward)
+ useEffect(() => {
+ setSearch(urlSearch);
+ }, [urlSearch]);
+
+ // ── helpers ─────────────────────────────────────────────────────────────────
+
+ /** Push a filter change into the URL without adding a history entry */
+ const updateURL = useCallback(
+ (patches) => {
+ const params = new URLSearchParams(searchParams.toString());
+ Object.entries(patches).forEach(([k, v]) => {
+ if (v) {
+ params.set(k, v);
+ } else {
+ params.delete(k);
+ }
+ });
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
+ },
+ [router, pathname, searchParams]
+ );
+
+ const handleCategoryClick = (slug) => {
+ updateURL({ category: urlCategory === slug ? "" : slug });
+ };
+
+ const handleSortChange = (e) => {
+ updateURL({ sort: e.target.value });
+ };
+
+ const handleSearchSubmit = (e) => {
+ e.preventDefault();
+ updateURL({ q: search });
+ };
+
+ const handleClearFilters = () => {
+ setSearch("");
+ router.replace(pathname, { scroll: false });
+ };
+
+ // ── data fetching ────────────────────────────────────────────────────────────
+
+ const fetchData = useCallback(async () => {
setLoading(true);
setError(false);
try {
if (showBookmarks) {
const response = await getBookmarkedCourses();
- setCourses(response.bookmarks || []);
+ setAllCourses(response.bookmarks || []);
} else {
const response = await fetchCourses();
- setCourses(response);
+ setAllCourses(response);
}
- } catch (error) {
+ } catch {
setError(true);
} finally {
setLoading(false);
}
- };
+ }, [showBookmarks]);
useEffect(() => {
fetchData();
- }, [showBookmarks]);
+ }, [fetchData]);
+
+ // ── derived / filtered list ─────────────────────────────────────────────────
+
+ const displayedCourses = useMemo(() => {
+ let list = allCourses.filter(
+ (course) => showBookmarks || course?.createdBy?._id !== user?._id
+ );
+
+ // Category filter
+ if (urlCategory) {
+ list = list.filter((course) => resolveSlug(course.category) === urlCategory);
+ }
+
+ // Text search filter
+ if (urlSearch) {
+ const q = urlSearch.toLowerCase();
+ list = list.filter(
+ (course) =>
+ course.title?.toLowerCase().includes(q) ||
+ course.description?.toLowerCase().includes(q) ||
+ course.category?.toLowerCase().includes(q)
+ );
+ }
+
+ return sortCourses(list, urlSort);
+ }, [allCourses, urlCategory, urlSearch, urlSort, showBookmarks, user]);
+
+ const hasActiveFilters = urlCategory || urlSearch || urlSort !== "newest";
+
+ // ── render ───────────────────────────────────────────────────────────────────
if (error) {
return (
@@ -52,11 +170,19 @@ export default function CoursesPage() {
return (
<>
-
+ {/* ── Top bar ── */}
+
{showBookmarks ? "My Bookmarked Courses" : "All Courses"}
-
+
+
+
+ Browse Categories
+
-
+ {/* ── Filters bar ── */}
+ {!showBookmarks && (
+
+ {/* Text search + sort row */}
+
+ {/* Search input */}
+
+
+ {/* Sort select */}
+
+ {SORT_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ {/* Clear filters */}
+ {hasActiveFilters && (
+
+
+ Clear filters
+
+ )}
+
+
+ {/* Category chips — horizontally scrollable */}
+
+ {CATEGORIES.map((cat) => {
+ const isActive = urlCategory === cat.slug;
+ return (
+ handleCategoryClick(cat.slug)}
+ className={`flex-shrink-0 flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-semibold border transition-all ${
+ isActive
+ ? "bg-accent text-white border-accent shadow"
+ : "bg-background border-input text-foreground hover:border-accent hover:text-accent"
+ }`}
+ aria-pressed={isActive}
+ >
+ {cat.icon}
+ {cat.label}
+
+ );
+ })}
+
+
+ {/* Active category label */}
+ {urlCategory && (
+
+ Showing courses in:{" "}
+
+ {CATEGORIES.find((c) => c.slug === urlCategory)?.label ||
+ urlCategory}
+
+
+ )}
+
+ )}
+
+ {/* ── Course grid ── */}
+
{loading ? (
-
+
{[...Array(6)].map((_, idx) => (
))}
- ) : courses.length === 0 ? (
+ ) : displayedCourses.length === 0 ? (
{showBookmarks
? "No bookmarked courses yet. Start bookmarking courses you're interested in!"
+ : urlCategory || urlSearch
+ ? "No courses match your filters. Try adjusting or clearing them."
: "No courses available at the moment."}
+ {(urlCategory || urlSearch) && (
+
+
+ Clear filters
+
+ )}
) : (
-
- {courses
- .filter(
- (course) =>
- showBookmarks || course?.createdBy?._id !== user?._id
- )
- .map((course) => (
-
{
- // If in bookmarks view and unbookmarked, remove from list
- if (showBookmarks && !isBookmarked) {
- setCourses(courses.filter((c) => c._id !== course._id));
- }
- }}
- />
- ))}
+
+ {displayedCourses.map((course) => (
+ {
+ if (showBookmarks && !isBookmarked) {
+ setAllCourses((prev) =>
+ prev.filter((c) => c._id !== course._id)
+ );
+ }
+ }}
+ />
+ ))}
)}
+
- {value || "Select category..."}
+ {displayLabel}
@@ -42,23 +52,24 @@ export default function CategoryCombobox({ category, setCategory }) {
No category found.
- {islamicCategories.map((group) => (
-
- {group.subcategories.map((subcategory) => (
+ {groups.map(({ group, categories }) => (
+
+ {categories.map((cat) => (
{
- setValue(currentValue === value ? "" : currentValue);
+ // Toggle off if same value is selected again
+ setCategory(currentValue === category ? "" : currentValue);
setOpen(false);
- setCategory(currentValue);
}}
>
- {subcategory}
+ {cat.icon}
+ {cat.label}
@@ -68,6 +79,6 @@ export default function CategoryCombobox({ category, setCategory }) {
-
+
);
}
diff --git a/components/molecules/dashboard/cards/courseCard.jsx b/components/molecules/dashboard/cards/courseCard.jsx
index a1147df6..f7f68e01 100644
--- a/components/molecules/dashboard/cards/courseCard.jsx
+++ b/components/molecules/dashboard/cards/courseCard.jsx
@@ -1,3 +1,5 @@
+"use client";
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import Button from "@/components/atoms/form/Button";
@@ -7,6 +9,7 @@ import useAuth from "@/hooks/useAuth";
import Image from "next/image";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useBookmark } from "@/hooks/useBookmark";
+import { resolveSlug } from "@/lib/categories";
const CourseCard = ({ course, onBookmarkChange }) => {
const { user } = useAuth();
@@ -20,6 +23,12 @@ const CourseCard = ({ course, onBookmarkChange }) => {
e.stopPropagation();
await toggle();
};
+
+ // Resolve the stored category string to a known slug.
+ // Unknown / legacy values get slug=null → fallback to decorative badge only.
+ const categorySlug = resolveSlug(course.category);
+ const categoryLabel = course.category || "General";
+
return (
{/* Image */}
@@ -33,11 +42,22 @@ const CourseCard = ({ course, onBookmarkChange }) => {
/>
- {/* Category */}
+ {/* Category badge — links to category landing page when slug is known */}
-
- {course.category || "General"}
-
+ {categorySlug ? (
+
e.stopPropagation()}
+ >
+
+ {categoryLabel}
+
+
+ ) : (
+
+ {categoryLabel}
+
+ )}
{user?._id === course?.createdBy?._id ? (
@@ -98,7 +118,7 @@ const CourseCard = ({ course, onBookmarkChange }) => {
{/* Full-width Button */}
-
+
[c.slug, c]));
+
+/** Normalised label → slug (for mapping legacy free-text category values) */
+const _labelToSlug = Object.fromEntries(
+ CATEGORIES.map((c) => [c.label.toLowerCase().trim(), c.slug])
+);
+
+/**
+ * Get a Category by its slug.
+ * Returns `undefined` when not found — callers must handle this gracefully.
+ * @param {string} slug
+ * @returns {Category | undefined}
+ */
+export function getCategoryBySlug(slug) {
+ return _bySlug[slug];
+}
+
+/**
+ * Resolve a raw category string (as stored on a course document) to a known
+ * slug. Falls back to `null` when no match is found and logs a warning so
+ * that legacy / mismatched data is visible during development.
+ *
+ * @param {string | undefined | null} raw – the value stored on the course
+ * @returns {string | null} – a slug from CATEGORIES, or null
+ */
+export function resolveSlug(raw) {
+ if (!raw) return null;
+ const normalised = raw.toLowerCase().trim();
+ // Exact slug match
+ if (_bySlug[normalised]) return normalised;
+ // Exact label match
+ if (_labelToSlug[normalised]) return _labelToSlug[normalised];
+ // Partial label match (handles e.g. "Fiqh (Islamic Jurisprudence)" stored as just "Fiqh")
+ const partialMatch = CATEGORIES.find((c) =>
+ c.label.toLowerCase().includes(normalised) ||
+ normalised.includes(c.label.toLowerCase())
+ );
+ if (partialMatch) return partialMatch.slug;
+ console.warn(
+ `[categories] Unknown category value "${raw}" — falling back to "general" bucket.`
+ );
+ return null;
+}
+
+/**
+ * Get categories grouped by their parent group, as an array of objects.
+ * Useful for building grouped UIs (ComboBox, category hub grid).
+ *
+ * @returns {{ group: string, categories: Category[] }[]}
+ */
+export function getGroupedCategories() {
+ return CATEGORY_GROUPS.map((group) => ({
+ group,
+ categories: CATEGORIES.filter((c) => c.group === group),
+ }));
+}
+
+// ---------------------------------------------------------------------------
+// 4. islamicCategories shim
+// Keeps lib/data.js consumers working without change while ComboBox and
+// new pages switch over to this module's richer shape.
+// ---------------------------------------------------------------------------
+
+/**
+ * Drop-in replacement for the `islamicCategories` array from lib/data.js.
+ * Shape: [{ main: string, subcategories: string[] }]
+ */
+export const islamicCategoriesCompat = CATEGORY_GROUPS.map((group) => ({
+ main: group,
+ subcategories: CATEGORIES.filter((c) => c.group === group).map(
+ (c) => c.label
+ ),
+}));
+
+// ---------------------------------------------------------------------------
+// 5. Course-count derivation (client-side, backend-ready)
+// ---------------------------------------------------------------------------
+
+/**
+ * Given a flat array of course objects (each with a `category` string field),
+ * returns a map from slug → count.
+ *
+ * When the backend ships GET /api/courses/categories, replace this function
+ * body with a fetch call — the return type stays identical so callers need
+ * zero changes.
+ *
+ * @param {Array<{ category?: string }>} courses
+ * @returns {Record}
+ */
+export function getCategoryCounts(courses) {
+ /** @type {Record} */
+ const counts = {};
+ for (const course of courses) {
+ const slug = resolveSlug(course.category);
+ if (slug) {
+ counts[slug] = (counts[slug] || 0) + 1;
+ }
+ // Courses with unknown categories are counted under the fallback UI only,
+ // not attributed to any taxonomy slot.
+ }
+ return counts;
+}