From d076f6bbf5fd70e2c80bad50f32b88efaed4496a Mon Sep 17 00:00:00 2001 From: dehonesty2-svg Date: Wed, 22 Jul 2026 15:43:53 +0000 Subject: [PATCH] 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 ++++++++++++ app/dashboard/courses/page.jsx | 286 ++++++++++++-- components/atoms/form/ComboBox.jsx | 39 +- .../molecules/dashboard/cards/courseCard.jsx | 30 +- lib/categories.js | 371 ++++++++++++++++++ 6 files changed, 1103 insertions(+), 50 deletions(-) create mode 100644 app/dashboard/courses/categories/page.jsx create mode 100644 app/dashboard/courses/category/[slug]/page.jsx create mode 100644 lib/categories.js 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/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 */} +
+
+ + setSearch(e.target.value)} + placeholder="Search courses..." + className="w-full pl-9 pr-3 py-2 text-sm rounded-full border border-input bg-background focus:outline-none focus:ring-2 focus:ring-accent/50" + /> +
+ +
+ + {/* Sort select */} + + + {/* Clear filters */} + {hasActiveFilters && ( + + )} +
+ + {/* Category chips — horizontally scrollable */} +
+ {CATEGORIES.map((cat) => { + const isActive = urlCategory === cat.slug; + return ( + + ); + })} +
+ + {/* 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) && ( + + )}
) : ( -
- {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) + ); + } + }} + /> + ))}
)}
+ @@ -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 */} -
+