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 new file mode 100644 index 00000000..d1514257 --- /dev/null +++ b/app/dashboard/courses/categories/page.jsx @@ -0,0 +1,182 @@ +"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(); + if (!data) throw new Error("No data returned"); + setCourses(data); + } catch (err) { + console.error("[CategoryHub] Failed to load courses:", err); + 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..1786438c --- /dev/null +++ b/app/dashboard/courses/category/[slug]/page.jsx @@ -0,0 +1,249 @@ +"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(); + if (!data) throw new Error("No data returned"); + setAllCourses(data); + } catch (err) { + console.error("[CategoryLanding] Failed to load courses:", err); + 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/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} + + ); +} 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 ? ( })) ); +// 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]) ); @@ -426,10 +429,44 @@ export function resolveCategorySlug(value) { return FALLBACK_SLUG; } +/** + * Resolve a raw category string to a known slug or null. + * Returns null (not FALLBACK_SLUG) when no match is found, so callers can + * distinguish "no match" from "matched the fallback". + * + * @param {string | undefined | null} raw + * @returns {string | null} + */ +export function resolveSlug(raw) { + if (!raw) return null; + const normalised = raw.toLowerCase().trim(); + if (!normalised) return null; + + // Exact slug match + if (Object.hasOwn(CATEGORY_MAP, normalised)) return normalised; + // Exact label match + const byLabel = ISLAMIC_CATEGORIES.find( + (c) => 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; - return CATEGORY_MAP[slug] || null; + return Object.hasOwn(CATEGORY_MAP, slug) ? CATEGORY_MAP[slug] : null; } export function getCategoryLabel(slug) { @@ -437,6 +474,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) {