diff --git a/app/dashboard/saved/page.jsx b/app/dashboard/saved/page.jsx new file mode 100644 index 00000000..44d7c7fb --- /dev/null +++ b/app/dashboard/saved/page.jsx @@ -0,0 +1,203 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import CourseCard from "@/components/molecules/dashboard/cards/courseCard"; +import LibraryBookCard from "@/components/molecules/dashboard/cards/libraryCard"; +import CourseCardSkeleton from "@/components/atoms/skeletons/CourseCardSkeleton"; +import LibraryBookSkeleton from "@/components/atoms/skeletons/LibraryBookSkeleton"; +import NetworkErrorComp from "@/components/molecules/errors/NetworkError"; +import Button from "@/components/atoms/form/Button"; +import { getBookmarkedCourses } from "@/lib/actions/courses/bookmark-course"; +import { getBookmarkedBooks } from "@/lib/actions/library/bookmark-book"; +import { Bookmark, LaptopMinimal, Book } from "lucide-react"; + +export default function SavedPage() { + const [activeTab, setActiveTab] = useState("courses"); // "courses" | "books" + const [courses, setCourses] = useState([]); + const [books, setBooks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const removedCoursesRef = useRef({}); + const removedBooksRef = useRef({}); + + const fetchSavedItems = async () => { + setLoading(true); + setError(false); + try { + const [coursesRes, booksRes] = await Promise.allSettled([ + getBookmarkedCourses(), + getBookmarkedBooks(), + ]); + + if (coursesRes.status === "fulfilled") { + const courseData = coursesRes.value; + setCourses(courseData?.bookmarks || (Array.isArray(courseData) ? courseData : [])); + } + + if (booksRes.status === "fulfilled") { + const bookData = booksRes.value; + setBooks(bookData?.bookmarks || (Array.isArray(bookData) ? bookData : [])); + } + + if (coursesRes.status === "rejected" && booksRes.status === "rejected") { + setError(true); + } + } catch (_err) { + setError(true); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSavedItems(); + }, []); + + const handleCourseBookmarkChange = (isBookmarked, courseId) => { + if (!isBookmarked) { + setCourses((prev) => { + const removed = prev.find((c) => c._id === courseId); + if (removed) removedCoursesRef.current[courseId] = removed; + return prev.filter((c) => c._id !== courseId); + }); + } else if (removedCoursesRef.current[courseId]) { + setCourses((prev) => [...prev, removedCoursesRef.current[courseId]]); + delete removedCoursesRef.current[courseId]; + } + }; + + const handleBookBookmarkChange = (isBookmarked, bookId) => { + if (!isBookmarked) { + setBooks((prev) => { + const removed = prev.find((b) => b._id === bookId); + if (removed) removedBooksRef.current[bookId] = removed; + return prev.filter((b) => b._id !== bookId); + }); + } else if (removedBooksRef.current[bookId]) { + setBooks((prev) => [...prev, removedBooksRef.current[bookId]]); + delete removedBooksRef.current[bookId]; + } + }; + + if (error) { + return ( + fetchSavedItems()} + /> + ); + } + + return ( +
+ {/* Header */} +
+
+

+ + My Saved Hub +

+

+ Access all your bookmarked courses and books in one place +

+
+ + {/* Tabs Toggle */} +
+ + + +
+
+ + {/* Content Section */} +
+ {loading ? ( +
+ {[...Array(6)].map((_, idx) => + activeTab === "courses" ? ( + + ) : ( + + ) + )} +
+ ) : activeTab === "courses" ? ( + courses.length === 0 ? ( +
+ +

No saved courses yet

+

+ Explore our catalog and bookmark courses you are interested in to view them here later. +

+ +
+ ) : ( +
+ {courses.map((course) => ( + + handleCourseBookmarkChange(isBookmarked, course._id) + } + /> + ))} +
+ ) + ) : books.length === 0 ? ( +
+ +

No saved books yet

+

+ Browse our Islamic library and save books to build your personal reading list. +

+ +
+ ) : ( +
+ {books.map((book) => ( + + handleBookBookmarkChange(isBookmarked, book._id) + } + /> + ))} +
+ )} +
+
+ ); +} diff --git a/components/atoms/BookmarkButton.jsx b/components/atoms/BookmarkButton.jsx new file mode 100644 index 00000000..37917e70 --- /dev/null +++ b/components/atoms/BookmarkButton.jsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Bookmark, BookmarkCheck, CirclePlus, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Reusable BookmarkButton component with consistent filled/outlined and loading states. + * + * @param {Object} props + * @param {boolean} props.isBookmarked - Bookmark active state + * @param {boolean} [props.loading] - Loading state during API request + * @param {function} props.onClick - Click handler + * @param {string} [props.variant="course"] - "course" | "book" icon variant + * @param {string} [props.title] - Hover title + * @param {string} [props.ariaLabel] - Accessible label + * @param {string} [props.className] - Additional class names + */ +const BookmarkButton = ({ + isBookmarked, + loading = false, + onClick, + variant = "course", + title, + ariaLabel, + className, +}) => { + const defaultTitle = isBookmarked ? "Remove bookmark" : "Add bookmark"; + const buttonTitle = title || defaultTitle; + const buttonAriaLabel = ariaLabel || defaultTitle; + + if (variant === "book") { + return ( + + ); + } + + return ( + + ); +}; + +export default BookmarkButton; diff --git a/components/molecules/dashboard/cards/courseCard.jsx b/components/molecules/dashboard/cards/courseCard.jsx index a1147df6..8886f9e1 100644 --- a/components/molecules/dashboard/cards/courseCard.jsx +++ b/components/molecules/dashboard/cards/courseCard.jsx @@ -2,17 +2,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import Button from "@/components/atoms/form/Button"; import Link from "next/link"; -import { Ellipsis, Bookmark, BookmarkCheck } from "lucide-react"; +import { Ellipsis } from "lucide-react"; import useAuth from "@/hooks/useAuth"; import Image from "next/image"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useBookmark } from "@/hooks/useBookmark"; +import BookmarkButton from "@/components/atoms/BookmarkButton"; -const CourseCard = ({ course, onBookmarkChange }) => { +const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked }) => { const { user } = useAuth(); const { isBookmarked, loading, toggle } = useBookmark( course._id, - onBookmarkChange + onBookmarkChange, + initialIsBookmarked ); const handleBookmark = async (e) => { @@ -81,18 +83,12 @@ const CourseCard = ({ course, onBookmarkChange }) => {
{course.price ? `$${course.price}` : "Free"}
- + variant="course" + /> diff --git a/components/molecules/dashboard/cards/libraryCard.jsx b/components/molecules/dashboard/cards/libraryCard.jsx index 766267fe..61ae03cf 100644 --- a/components/molecules/dashboard/cards/libraryCard.jsx +++ b/components/molecules/dashboard/cards/libraryCard.jsx @@ -4,14 +4,14 @@ import { Star } from "lucide-react"; // optional: use a custom star icon or emoj import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import Link from "next/link"; import { getAverageRating } from "@/hooks/getAverageRating"; -import { CirclePlus } from "lucide-react"; -import { cn } from "@/lib/utils"; import useBookBookmark from "@/hooks/useBookBookmark"; +import BookmarkButton from "@/components/atoms/BookmarkButton"; -const LibraryBookCard = ({ book, onBookmarkChange }) => { +const LibraryBookCard = ({ book, onBookmarkChange, initialIsBookmarked }) => { const { isBookmarked, loading, toggle } = useBookBookmark( book._id, - onBookmarkChange + onBookmarkChange, + initialIsBookmarked ); const handleBookmark = async (event) => { @@ -66,24 +66,12 @@ const LibraryBookCard = ({ book, onBookmarkChange }) => { {/* Bookmark button */} - + variant="book" + /> {/* Reads & Rating */}
diff --git a/components/molecules/dashboard/nav-routers.jsx b/components/molecules/dashboard/nav-routers.jsx index 48b4251a..d8dffedf 100644 --- a/components/molecules/dashboard/nav-routers.jsx +++ b/components/molecules/dashboard/nav-routers.jsx @@ -12,6 +12,7 @@ import { HeartHandshake, ShoppingBag, DollarSign, + Bookmark, } from "lucide-react"; import { SidebarGroup, @@ -37,6 +38,11 @@ const links = [ link: "/dashboard/library", icon: Book, }, + { + name: "Saved", + link: "/dashboard/saved", + icon: Bookmark, + }, { name: "Spaces", link: "/dashboard/spaces", diff --git a/hooks/useBookBookmark.js b/hooks/useBookBookmark.js index 4bd8d208..9613d6df 100644 --- a/hooks/useBookBookmark.js +++ b/hooks/useBookBookmark.js @@ -1,58 +1,14 @@ -import { useEffect, useState } from "react"; -import { - toggleBookBookmark, - checkIfBookBookmarked, -} from "@/lib/actions/library/bookmark-book"; -import { toast } from "sonner"; -import useAuth from "./useAuth"; - -export const useBookBookmark = (bookId, onToggle) => { - const { user } = useAuth(); - const [isBookmarked, setIsBookmarked] = useState(false); - const [loading, setLoading] = useState(false); - - useEffect(() => { - const checkBookmark = async () => { - if (user?._id && bookId) { - try { - const bookmarked = await checkIfBookBookmarked(bookId); - setIsBookmarked(Boolean(bookmarked)); - } catch (_error) { - // ignore - } - } - }; - - checkBookmark(); - }, [user?._id, bookId]); - - const toggle = async () => { - if (!user) { - toast.error("Please login to bookmark books"); - return; - } - - setLoading(true); - try { - const result = await toggleBookBookmark(bookId); - setIsBookmarked(result.isBookmarked); - toast.success(result.message); - if (onToggle) { - onToggle(result.isBookmarked); - } - } catch (_error) { - toast.error("Failed to update book bookmark"); - } finally { - setLoading(false); - } - }; - - return { - isBookmarked, - loading, - toggle, - }; +import { useBookmarkCore } from "./useBookmarkCore"; + +/** + * Custom hook for managing book bookmarks + * @param {string} bookId - The book ID + * @param {function} [onToggle] - Optional callback when bookmark is toggled + * @param {boolean|null} [initialIsBookmarked=null] - Optional pre-seeded bookmark state + * @returns {Object} - Bookmark state and toggle function + */ +export const useBookBookmark = (bookId, onToggle, initialIsBookmarked = null) => { + return useBookmarkCore(bookId, "book", onToggle, initialIsBookmarked); }; export default useBookBookmark; - diff --git a/hooks/useBookmark.js b/hooks/useBookmark.js index d16ec7a6..49451cf7 100644 --- a/hooks/useBookmark.js +++ b/hooks/useBookmark.js @@ -1,65 +1,14 @@ -import { useState, useEffect } from "react"; -import { - toggleCourseBookmark, - checkIfBookmarked, -} from "@/lib/actions/courses/bookmark-course"; -import { toast } from "sonner"; -import useAuth from "./useAuth"; +import { useBookmarkCore } from "./useBookmarkCore"; /** * Custom hook for managing course bookmarks * @param {string} courseId - The course ID - * @param {function} onToggle - Optional callback when bookmark is toggled + * @param {function} [onToggle] - Optional callback when bookmark is toggled + * @param {boolean|null} [initialIsBookmarked=null] - Optional pre-seeded bookmark state * @returns {Object} - Bookmark state and toggle function */ -export const useBookmark = (courseId, onToggle) => { - const { user } = useAuth(); - const [isBookmarked, setIsBookmarked] = useState(false); - const [loading, setLoading] = useState(false); - - // Check if course is bookmarked on mount - useEffect(() => { - const checkBookmark = async () => { - if (user?._id && courseId) { - try { - const bookmarked = await checkIfBookmarked(courseId); - setIsBookmarked(bookmarked); - } catch (error) { - // Silently fail - not critical - } - } - }; - checkBookmark(); - }, [user?._id, courseId]); - - const toggle = async () => { - if (!user) { - toast.error("Please login to bookmark courses"); - return; - } - - setLoading(true); - try { - const result = await toggleCourseBookmark(courseId); - setIsBookmarked(result.isBookmarked); - toast.success(result.message); - - // Call optional callback - if (onToggle) { - onToggle(result.isBookmarked); - } - } catch (error) { - toast.error("Failed to update bookmark"); - } finally { - setLoading(false); - } - }; - - return { - isBookmarked, - loading, - toggle, - }; +export const useBookmark = (courseId, onToggle, initialIsBookmarked = null) => { + return useBookmarkCore(courseId, "course", onToggle, initialIsBookmarked); }; export default useBookmark; diff --git a/hooks/useBookmarkCore.js b/hooks/useBookmarkCore.js new file mode 100644 index 00000000..6aa96412 --- /dev/null +++ b/hooks/useBookmarkCore.js @@ -0,0 +1,125 @@ +import { useState, useEffect, useRef } from "react"; +import { + toggleCourseBookmark, + checkIfBookmarked, +} from "@/lib/actions/courses/bookmark-course"; +import { + toggleBookBookmark, + checkIfBookBookmarked, +} from "@/lib/actions/library/bookmark-book"; +import { toast } from "sonner"; +import useAuth from "./useAuth"; + +/** + * Core custom hook for managing bookmarks (courses & books) + * @param {string} itemId - Item ID (course or book ID) + * @param {'course'|'book'} type - Type of item + * @param {function} [onToggle] - Optional callback when bookmark is toggled + * @param {boolean|null} [initialIsBookmarked=null] - Pre-seeded bookmark state to avoid mount check API call + * @returns {Object} - { isBookmarked, loading, toggle } + */ +export const useBookmarkCore = ( + itemId, + type = "course", + onToggle, + initialIsBookmarked = null +) => { + const { user } = useAuth(); + const hasToggledRef = useRef(false); + const [isBookmarked, setIsBookmarked] = useState(() => { + return typeof initialIsBookmarked === "boolean" ? initialIsBookmarked : false; + }); + const [loading, setLoading] = useState(false); + + // Sync initialIsBookmarked if it changes dynamically + useEffect(() => { + if (typeof initialIsBookmarked === "boolean") { + setIsBookmarked(initialIsBookmarked); + } + }, [initialIsBookmarked]); + + // Check bookmark status on mount only if initialIsBookmarked was not provided + useEffect(() => { + const checkBookmark = async () => { + if (typeof initialIsBookmarked === "boolean") { + return; // Skip network request when pre-seeded + } + + if (user?._id && itemId) { + try { + if (type === "course") { + const bookmarked = await checkIfBookmarked(itemId); + if (!hasToggledRef.current) { + setIsBookmarked(Boolean(bookmarked)); + } + } else if (type === "book") { + const bookmarked = await checkIfBookBookmarked(itemId); + if (!hasToggledRef.current) { + setIsBookmarked(Boolean(bookmarked)); + } + } + } catch (_error) { + // Silently handle error for check request + } + } + }; + + checkBookmark(); + }, [user?._id, itemId, type, initialIsBookmarked]); + + const toggle = async () => { + if (!user) { + toast.error(`Please login to bookmark ${type === "book" ? "books" : "courses"}`); + return; + } + + hasToggledRef.current = true; + const previousState = isBookmarked; + const nextState = !previousState; + + // Optimistic update + setIsBookmarked(nextState); + if (onToggle) { + onToggle(nextState); + } + + setLoading(true); + + try { + let result; + if (type === "course") { + result = await toggleCourseBookmark(itemId); + } else { + result = await toggleBookBookmark(itemId); + } + + if (result && typeof result.isBookmarked === "boolean") { + setIsBookmarked(result.isBookmarked); + } + if (result?.message) { + toast.success(result.message); + } + } catch (error) { + // Revert optimistic update on failure + setIsBookmarked(previousState); + if (onToggle) { + onToggle(previousState); + } + const errorMessage = + error?.message || + error?.error || + `Failed to update ${type === "book" ? "book bookmark" : "bookmark"}`; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return { + isBookmarked, + loading, + toggle, + }; +}; + +export default useBookmarkCore;