From d174c4d2a36791f6330239f25ddff06d3d4350b8 Mon Sep 17 00:00:00 2001 From: dehonesty2-svg Date: Wed, 22 Jul 2026 15:43:53 +0000 Subject: [PATCH 1/3] feat: course category taxonomy, hub, landing pages & URL filters - Add lib/categories.js as single source of truth for 31 Islamic categories (slug, label, group, description, icon) across 6 groups. Includes resolveSlug() with legacy-value fallback + console.warn, getCategoryCounts() for client-side counts (backend-ready), getGroupedCategories(), and an islamicCategoriesCompat shim. - Fix ComboBox.jsx: remove internal value state bug; now fully controlled by the category prop; imports from lib/categories.js. - Update courseCard.jsx: category badge links to the category landing page via resolveSlug(); unknown/legacy categories render as a plain decorative badge (no crash). - Update courses page with URL-driven filters: horizontally scrollable category chips, text search, sort (newest/price/rating), all state persisted in ?category=&sort=&q= query params; survives refresh. - Add /dashboard/courses/categories: browsable hub showing all 6 groups and 31 category cards with live course counts; empty categories are de-emphasised (not hidden). - Add /dashboard/courses/category/[slug]: landing page with hero, breadcrumb, filtered & sorted course grid, empty state with educator CTA, not-found state for unknown slugs. Closes #113 --- app/dashboard/courses/categories/page.jsx | 180 +++++++++++++ .../courses/category/[slug]/page.jsx | 247 ++++++++++++++++++ .../molecules/dashboard/cards/courseCard.jsx | 38 ++- lib/categories.js | 51 +++- 4 files changed, 500 insertions(+), 16 deletions(-) create mode 100644 app/dashboard/courses/categories/page.jsx create mode 100644 app/dashboard/courses/category/[slug]/page.jsx 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 */} + + + {/* 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 */} + + {/* Create course CTA */} + +
+
+ + {/* ── 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! +

+ + + Browse other categories + +
+ ) : ( +
+ {categoryCourses.map((course) => ( + + ))} +
+ )} +
+
+ + {/* Create Course modal */} + setModalOpen(false)} + className="max-w-md w-full" + > + + + + ); +} diff --git a/components/molecules/dashboard/cards/courseCard.jsx b/components/molecules/dashboard/cards/courseCard.jsx index 03fd08bd..505ce1ec 100644 --- a/components/molecules/dashboard/cards/courseCard.jsx +++ b/components/molecules/dashboard/cards/courseCard.jsx @@ -1,4 +1,5 @@ import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; import Button from "@/components/atoms/form/Button"; import Link from "next/link"; import { Ellipsis, CheckCircle } from "lucide-react"; @@ -7,7 +8,7 @@ import Image from "next/image"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useBookmark } from "@/hooks/useBookmark"; import BookmarkButton from "@/components/atoms/BookmarkButton"; -import { resolveCategorySlug } from "@/lib/categories"; +import { resolveSlug } from "@/lib/categories"; import { cn } from "@/lib/utils"; import { poppins_400, @@ -39,6 +40,12 @@ const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked, progress }) 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 */} @@ -52,19 +59,22 @@ const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked, progress }) />
- {/* Category */} -
- - {course.category || "General"} - + {/* Category badge — links to category landing page when slug is known */} +
+ {categorySlug ? ( + e.stopPropagation()} + > + + {categoryLabel} + + + ) : ( + + {categoryLabel} + + )} {user?._id === course?.createdBy?._id ? ( c.label.toLowerCase().trim() === normalised + ); + if (byLabel) return byLabel.slug; + + // Partial label match — only return if EXACTLY ONE match (unambiguous) + const partialMatches = ISLAMIC_CATEGORIES.filter( + (c) => + c.label.toLowerCase().includes(normalised) || + normalised.includes(c.label.toLowerCase()) + ); + if (partialMatches.length === 1) return partialMatches[0].slug; + + // Unknown value — log and return null + console.warn(`[resolveSlug] Unknown category value: "${raw}"`); + return null; +} + export function getCategoryBySlug(slug) { if (!slug) return null; if (slug === FALLBACK_SLUG) return FALLBACK_CATEGORY; @@ -437,6 +471,19 @@ export function getCategoryLabel(slug) { return category ? category.label : "General"; } +/** + * 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: group.label, + categories: group.categories, + })); +} + // Course list -> { slug: count }. Empty / legacy / free-text values count // toward the fallback bucket. export function getCategoryCounts(courses) { From 6f1f67858e37a81861187f822414eb9ec3f65956 Mon Sep 17 00:00:00 2001 From: Oluwatoyin Solomon Date: Wed, 2 Sep 2026 13:19:36 +0100 Subject: [PATCH 2/3] fix: address CodeRabbit review + resolve conflicts with dev - Guard _bySlug and _labelToSlug lookups with Object.hasOwn to reject inherited prototype keys (constructor, __proto__) - Fix resolveSlug: empty string now returns null before any lookup - Fix resolveSlug: partial matching now returns slug only on unique match (prevents ambiguous values like 'Islamic' resolving to wrong category) - Fix fetchCourses error handling: failures now surface to error state instead of silently collapsing to empty list in categories hub, category landing, and main courses page (bookmark branch unchanged) - Rebased onto latest upstream/dev to resolve merge conflicts --- app/[locale]/dashboard/courses/page.jsx | 4 +++- app/dashboard/courses/categories/page.jsx | 4 +++- app/dashboard/courses/category/[slug]/page.jsx | 4 +++- lib/categories.js | 5 ++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/[locale]/dashboard/courses/page.jsx b/app/[locale]/dashboard/courses/page.jsx index 3b1c7e2e..6f3cddcb 100644 --- a/app/[locale]/dashboard/courses/page.jsx +++ b/app/[locale]/dashboard/courses/page.jsx @@ -62,9 +62,11 @@ const CoursesPageContent = () => { setCourses(response.bookmarks || []); } else { const response = await fetchCourses(); + if (!response) throw new Error("No data returned"); setCourses(response); } - } catch (error) { + } catch (err) { + console.error("[CoursesPage] Failed to load courses:", err); setError(true); } finally { setLoading(false); diff --git a/app/dashboard/courses/categories/page.jsx b/app/dashboard/courses/categories/page.jsx index 26c3d741..d1514257 100644 --- a/app/dashboard/courses/categories/page.jsx +++ b/app/dashboard/courses/categories/page.jsx @@ -21,8 +21,10 @@ export default function CategoryHubPage() { setError(false); try { const data = await fetchCourses(); + if (!data) throw new Error("No data returned"); setCourses(data); - } catch { + } catch (err) { + console.error("[CategoryHub] Failed to load courses:", err); setError(true); } finally { setLoading(false); diff --git a/app/dashboard/courses/category/[slug]/page.jsx b/app/dashboard/courses/category/[slug]/page.jsx index 7bdc98a8..1786438c 100644 --- a/app/dashboard/courses/category/[slug]/page.jsx +++ b/app/dashboard/courses/category/[slug]/page.jsx @@ -59,8 +59,10 @@ export default function CategoryLandingPage({ params }) { setError(false); try { const data = await fetchCourses(); + if (!data) throw new Error("No data returned"); setAllCourses(data); - } catch { + } catch (err) { + console.error("[CategoryLanding] Failed to load courses:", err); setError(true); } finally { setLoading(false); diff --git a/lib/categories.js b/lib/categories.js index 23fe9b35..78806658 100644 --- a/lib/categories.js +++ b/lib/categories.js @@ -376,6 +376,9 @@ export const ISLAMIC_CATEGORIES = CATEGORY_GROUPS.flatMap((group) => })) ); +// Alias for backward compatibility — some pages import CATEGORIES directly. +export const CATEGORIES = ISLAMIC_CATEGORIES; + export const CATEGORY_MAP = Object.fromEntries( ISLAMIC_CATEGORIES.map((category) => [category.slug, category]) ); @@ -463,7 +466,7 @@ export function resolveSlug(raw) { export function getCategoryBySlug(slug) { if (!slug) return null; if (slug === FALLBACK_SLUG) return FALLBACK_CATEGORY; - return CATEGORY_MAP[slug] || null; + return Object.hasOwn(CATEGORY_MAP, slug) ? CATEGORY_MAP[slug] : null; } export function getCategoryLabel(slug) { From bb9b0805a11fb836dc3247afca667d719f496673 Mon Sep 17 00:00:00 2001 From: Oluwatoyin Solomon Date: Wed, 2 Sep 2026 14:25:15 +0100 Subject: [PATCH 3/3] fix: add missing root and dashboard layouts for Next.js build - Add app/layout.js root layout (required by Next.js for all pages) - Add app/dashboard/layout.jsx with sidebar and nav header - Fixes 'dashboard/courses/categories/page.jsx doesn't have a root layout' error --- app/dashboard/layout.jsx | 17 +++++++++++++++++ app/layout.js | 14 ++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 app/dashboard/layout.jsx create mode 100644 app/layout.js diff --git a/app/dashboard/layout.jsx b/app/dashboard/layout.jsx new file mode 100644 index 00000000..c3524f09 --- /dev/null +++ b/app/dashboard/layout.jsx @@ -0,0 +1,17 @@ +"use client"; + +import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; +import { SidebarLeft } from "@/components/organisms/dashboard/sidebar-left"; +import NavHeader from "@/components/molecules/dashboard/nav-header"; + +export default function DashboardLayout({ children }) { + return ( + + + + + {children} + + + ); +} diff --git a/app/layout.js b/app/layout.js new file mode 100644 index 00000000..ee5f8b66 --- /dev/null +++ b/app/layout.js @@ -0,0 +1,14 @@ +import "../styles/globals.css"; + +export const metadata = { + title: "Deen Bridge", + description: "Islamic education platform", +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +}