From 739408255749edbe0a674a6fae7c4b626fe0e207 Mon Sep 17 00:00:00 2001 From: BountySpaghetti Date: Sun, 26 Jul 2026 01:55:51 +0100 Subject: [PATCH] feat(educators): add public educator storefront pages - Add /educators/[profileid] public route accessible without authentication - Add EducatorProfileHeader with avatar, bio, role, stats, follower count - Add PublicCourseCard, PublicBookCard, PublicSpaceCard (no auth dependencies) - Add tabbed navigation for courses, books, and spaces - Add share button with Web Share API and clipboard fallback - Add auth-aware follow: logged-out users redirect to /login?next=... - Add privacy guard: shows not-found for missing/private profiles - Rewire instructor links on courseCard, libraryCard, spaceCard to /educators/[id] - Add 'View public page' link on authenticated profile page - Add generateMetadata for SEO (title, description, OpenGraph) Closes #116 --- .../[profileid]/EducatorPageClient.jsx | 271 ++++++++++++++++++ app/(pages)/educators/[profileid]/page.jsx | 24 ++ app/account/profile/[profileid]/page.jsx | 9 + .../molecules/dashboard/cards/courseCard.jsx | 2 +- .../cards/educators/PublicBookCard.jsx | 63 ++++ .../cards/educators/PublicCourseCard.jsx | 71 +++++ .../cards/educators/PublicSpaceCard.jsx | 97 +++++++ .../molecules/dashboard/cards/libraryCard.jsx | 2 +- .../molecules/dashboard/cards/spaceCard.jsx | 8 +- .../educators/EducatorProfileHeader.jsx | 146 ++++++++++ 10 files changed, 689 insertions(+), 4 deletions(-) create mode 100644 app/(pages)/educators/[profileid]/EducatorPageClient.jsx create mode 100644 app/(pages)/educators/[profileid]/page.jsx create mode 100644 components/molecules/dashboard/cards/educators/PublicBookCard.jsx create mode 100644 components/molecules/dashboard/cards/educators/PublicCourseCard.jsx create mode 100644 components/molecules/dashboard/cards/educators/PublicSpaceCard.jsx create mode 100644 components/organisms/educators/EducatorProfileHeader.jsx diff --git a/app/(pages)/educators/[profileid]/EducatorPageClient.jsx b/app/(pages)/educators/[profileid]/EducatorPageClient.jsx new file mode 100644 index 0000000..f4d92a6 --- /dev/null +++ b/app/(pages)/educators/[profileid]/EducatorPageClient.jsx @@ -0,0 +1,271 @@ +"use client"; +import React, { use, useState, useEffect } from "react"; +import Link from "next/link"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { getUserById } from "@/lib/actions/users/getUserById"; +import { fetchUserCourses } from "@/lib/actions/courses/fetch-user-id-courses"; +import { fetchUserBooks } from "@/lib/actions/library/fetch-user-id-books"; +import { fetchUserSpaces } from "@/lib/actions/spaces/fetchUserSpaces"; +import { + getFollowersCount, + checkIfFollowing, + followUser, + unfollowUser, +} from "@/hooks/useFollow"; +import { getAverageRating } from "@/hooks/getAverageRating"; +import EducatorProfileHeader from "@/components/organisms/educators/EducatorProfileHeader"; +import PublicCourseCard from "@/components/molecules/dashboard/cards/educators/PublicCourseCard"; +import PublicBookCard from "@/components/molecules/dashboard/cards/educators/PublicBookCard"; +import PublicSpaceCard from "@/components/molecules/dashboard/cards/educators/PublicSpaceCard"; +import NotFoundComp from "@/components/molecules/errors/NotFound"; +import NetworkErrorComp from "@/components/molecules/errors/NetworkError"; +import Loader from "@/components/molecules/loaders/rootLoader"; +import Footer from "@/components/molecules/ladingpage/Footer"; +import Navbar from "@/components/molecules/ladingpage/Navbar"; +import { Copy, Share2, Users, BookOpen, GraduationCap, Star } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import Button from "@/components/atoms/form/Button"; + +export default function PublicEducatorPage({ params }) { + const { profileid } = use(params); + const { user: currentUser } = useAuth(); + const router = useRouter(); + + const [educator, setEducator] = useState(null); + const [courses, setCourses] = useState([]); + const [books, setBooks] = useState([]); + const [spaces, setSpaces] = useState([]); + const [followersCount, setFollowersCount] = useState(0); + const [isFollowing, setIsFollowing] = useState(false); + const [followLoading, setFollowLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [activeTab, setActiveTab] = useState("courses"); + + useEffect(() => { + async function load() { + setLoading(true); + setError(false); + try { + const res = await getUserById(profileid); + const u = res?.user || null; + if (!u) { + setError(true); + setLoading(false); + return; + } + setEducator(u); + + const [coursesData, booksData, spacesData, followersRes] = + await Promise.allSettled([ + fetchUserCourses(profileid), + fetchUserBooks(profileid), + fetchUserSpaces(profileid), + getFollowersCount(profileid), + ]); + + if (coursesData.status === "fulfilled") { + setCourses(Array.isArray(coursesData.value) ? coursesData.value : []); + } + if (booksData.status === "fulfilled") { + setBooks(Array.isArray(booksData.value) ? booksData.value : []); + } + if (spacesData.status === "fulfilled") { + const sd = spacesData.value; + setSpaces(sd?.spaces || (Array.isArray(sd) ? sd : [])); + } + if (followersRes.status === "fulfilled" && followersRes.value?.success) { + setFollowersCount( + followersRes.value.followersCount || followersRes.value.count || 0 + ); + } + + if (currentUser?._id && currentUser._id !== profileid) { + const followRes = await checkIfFollowing(profileid); + if (followRes?.success) { + setIsFollowing(followRes.isFollowing); + } + } + } catch (e) { + setError(true); + } finally { + setLoading(false); + } + } + load(); + }, [profileid, currentUser?._id]); + + const handleFollowToggle = async () => { + if (!currentUser?._id) { + router.push(`/login?next=${encodeURIComponent(`/educators/${profileid}`)}`); + return; + } + setFollowLoading(true); + try { + const result = isFollowing + ? await unfollowUser(profileid) + : await followUser(profileid); + if (result.success) { + setIsFollowing(!isFollowing); + setFollowersCount((c) => (isFollowing ? c - 1 : c + 1)); + } else { + toast.error(result.message || "Failed to update follow status"); + } + } catch { + toast.error("Failed to update follow status"); + } finally { + setFollowLoading(false); + } + }; + + const handleShare = async () => { + const url = `${typeof window !== "undefined" ? window.location.origin : ""}/educators/${profileid}`; + if (navigator.share) { + try { + await navigator.share({ + title: educator?.name || "Educator Profile", + text: educator?.bio || `Check out ${educator?.name} on DeenBridge`, + url, + }); + } catch {} + } else { + try { + await navigator.clipboard.writeText(url); + toast.success("Link copied to clipboard!"); + } catch { + toast.error("Failed to copy link"); + } + } + }; + + if (loading) return ; + + if (error || !educator) { + return ( +
+ +
+ +
+
+
+ ); + } + + const hasContent = + courses.length > 0 || books.length > 0 || spaces.length > 0; + + if (!hasContent) { + return ( +
+ +
+ +
+ +

No public content yet

+

+ This educator hasn't published any courses, books, or spaces yet. +

+
+
+
+
+ ); + } + + const tabs = [ + { key: "courses", label: "Courses", count: courses.length, icon: GraduationCap }, + { key: "books", label: "Books", count: books.length, icon: BookOpen }, + { key: "spaces", label: "Spaces", count: spaces.length, icon: Users }, + ].filter((t) => t.count > 0); + + const allRatings = [ + ...courses.flatMap((c) => c.reviews || []), + ...books.flatMap((b) => b.reviews || []), + ]; + const avgRating = getAverageRating(allRatings); + + return ( +
+ +
+ + + {tabs.length > 1 && ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ )} + +
+ {activeTab === "courses" && courses.length > 0 && ( +
+ {courses.map((course) => ( + + ))} +
+ )} + {activeTab === "books" && books.length > 0 && ( +
+ {books.map((book) => ( + + ))} +
+ )} + {activeTab === "spaces" && spaces.length > 0 && ( +
+ {spaces.map((space) => ( + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/app/(pages)/educators/[profileid]/page.jsx b/app/(pages)/educators/[profileid]/page.jsx new file mode 100644 index 0000000..bb96482 --- /dev/null +++ b/app/(pages)/educators/[profileid]/page.jsx @@ -0,0 +1,24 @@ +import EducatorPageClient from "./EducatorPageClient"; + +export async function generateMetadata({ params }) { + const { profileid } = await params; + return { + title: "Educator Profile - Deen Bridge", + description: "View this educator's courses, books, and spaces on Deen Bridge.", + openGraph: { + title: "Educator Profile - Deen Bridge", + description: "View this educator's courses, books, and spaces on Deen Bridge.", + url: `https://deenbridge.com/educators/${profileid}`, + type: "profile", + }, + twitter: { + card: "summary_large_image", + title: "Educator Profile - Deen Bridge", + description: "View this educator's courses, books, and spaces on Deen Bridge.", + }, + }; +} + +export default function EducatorPage({ params }) { + return ; +} diff --git a/app/account/profile/[profileid]/page.jsx b/app/account/profile/[profileid]/page.jsx index 773b930..61edd75 100644 --- a/app/account/profile/[profileid]/page.jsx +++ b/app/account/profile/[profileid]/page.jsx @@ -1,5 +1,6 @@ "use client"; import React, { use, useState, useEffect } from "react"; +import Link from "next/link"; import ProfileHeader from "@/components/organisms/account/profile/ProfileHeader"; import ProfileUserInfo from "@/components/organisms/account/profile/ProfileUserInfo"; import ProfileTabs from "@/components/organisms/account/profile/ProfileTabs"; @@ -8,6 +9,7 @@ import { getUserById } from "@/lib/actions/users/getUserById"; import NotFoundComp from "@/components/molecules/errors/NotFound"; import NetworkErrorComp from "@/components/molecules/errors/NetworkError"; import Loader from "@/components/molecules/loaders/rootLoader"; +import { ExternalLink } from "lucide-react"; const page = ({ params }) => { const { profileid } = use(params); const [selectedTab, setSelectedTab] = useState("courses"); @@ -56,6 +58,13 @@ const page = ({ params }) => {
+ + + View public page +
diff --git a/components/molecules/dashboard/cards/courseCard.jsx b/components/molecules/dashboard/cards/courseCard.jsx index 8886f9e..48120c6 100644 --- a/components/molecules/dashboard/cards/courseCard.jsx +++ b/components/molecules/dashboard/cards/courseCard.jsx @@ -62,7 +62,7 @@ const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked }) => {
diff --git a/components/molecules/dashboard/cards/educators/PublicBookCard.jsx b/components/molecules/dashboard/cards/educators/PublicBookCard.jsx new file mode 100644 index 0000000..0df1546 --- /dev/null +++ b/components/molecules/dashboard/cards/educators/PublicBookCard.jsx @@ -0,0 +1,63 @@ +import Image from "next/image"; +import Button from "@/components/atoms/form/Button"; +import { Star } from "lucide-react"; +import { getAverageRating } from "@/hooks/getAverageRating"; + +const PublicBookCard = ({ book }) => { + const avgRating = getAverageRating(book?.reviews); + + return ( +
+
+ {book.title} +
+

{book.title}

+
+ + {book.category || "General"} + + + {book.price > 0 ? `$${book.price}` : "Free"} + +
+
+
+ +
+
+ {book.readCount || 0} readers + {avgRating > 0 && ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ )} +
+
+ +
+ +
+
+ ); +}; + +export default PublicBookCard; diff --git a/components/molecules/dashboard/cards/educators/PublicCourseCard.jsx b/components/molecules/dashboard/cards/educators/PublicCourseCard.jsx new file mode 100644 index 0000000..c107638 --- /dev/null +++ b/components/molecules/dashboard/cards/educators/PublicCourseCard.jsx @@ -0,0 +1,71 @@ +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 Image from "next/image"; +import { Star } from "lucide-react"; +import { getAverageRating } from "@/hooks/getAverageRating"; + +const PublicCourseCard = ({ course }) => { + const avgRating = getAverageRating(course?.reviews); + + return ( + +
+ {course.title} +
+
+ + {course.category || "General"} + +
+
+ + + + {course.title} + +

+ {course.description} +

+
+ + +
+
+ {avgRating > 0 && ( +
+ + + {avgRating.toFixed(1)} + +
+ )} +
+
+ {course.price ? `$${course.price}` : "Free"} +
+
+
+ +
+ +
+ + ); +}; + +export default PublicCourseCard; diff --git a/components/molecules/dashboard/cards/educators/PublicSpaceCard.jsx b/components/molecules/dashboard/cards/educators/PublicSpaceCard.jsx new file mode 100644 index 0000000..68de9b9 --- /dev/null +++ b/components/molecules/dashboard/cards/educators/PublicSpaceCard.jsx @@ -0,0 +1,97 @@ +"use client"; + +import { Card, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import Button from "@/components/atoms/form/Button"; +import Image from "next/image"; +import { VideoIcon, Clock } from "lucide-react"; +import { format } from "date-fns"; + +const PublicSpaceCard = ({ space }) => { + const { + _id, + title, + description, + thumbnail, + category, + status, + eventDate, + duration, + } = space; + + function formatDuration(minutes) { + if (minutes < 60) return `${minutes} mins`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins === 0 + ? `${hours} hr${hours > 1 ? "s" : ""}` + : `${hours} hr${hours > 1 ? "s" : ""} ${mins} min${mins > 1 ? "s" : ""}`; + } + + const formattedTime = eventDate + ? format(new Date(eventDate), "PPpp") + : "TBD"; + + return ( + +
+ {title} +
+
+ {category && ( + + {category} + + )} + {status && ( +
+ {status.toUpperCase()} +
+ )} +
+
+ +
+
+ + {title} + +

+ {description} +

+
+ +
+
+ + {formattedTime} +
+ {duration && ( +
+ {formatDuration(duration)} +
+ )} +
+ + +
+ + ); +}; + +export default PublicSpaceCard; diff --git a/components/molecules/dashboard/cards/libraryCard.jsx b/components/molecules/dashboard/cards/libraryCard.jsx index 61ae03c..da7505f 100644 --- a/components/molecules/dashboard/cards/libraryCard.jsx +++ b/components/molecules/dashboard/cards/libraryCard.jsx @@ -47,7 +47,7 @@ const LibraryBookCard = ({ book, onBookmarkChange, initialIsBookmarked }) => {
{/* Author */} diff --git a/components/molecules/dashboard/cards/spaceCard.jsx b/components/molecules/dashboard/cards/spaceCard.jsx index 6cbcb83..c206ce1 100644 --- a/components/molecules/dashboard/cards/spaceCard.jsx +++ b/components/molecules/dashboard/cards/spaceCard.jsx @@ -6,6 +6,7 @@ import { } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import Button from "@/components/atoms/form/Button"; +import Link from "next/link"; import Image from "next/image"; import { Avatar, @@ -90,7 +91,10 @@ const SpaceCard = ({ space }) => {
{/* Host */}
-
+ { {host?.name || "Ustadh Ahmad"} Host
-
+
diff --git a/components/organisms/educators/EducatorProfileHeader.jsx b/components/organisms/educators/EducatorProfileHeader.jsx new file mode 100644 index 0000000..63dacc6 --- /dev/null +++ b/components/organisms/educators/EducatorProfileHeader.jsx @@ -0,0 +1,146 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import Button from "@/components/atoms/form/Button"; +import { cn } from "@/lib/utils"; +import { + Copy, + Share2, + Users, + BookOpen, + GraduationCap, + Star, + Calendar, + ArrowLeft, +} from "lucide-react"; +import { format } from "date-fns"; + +const EducatorProfileHeader = ({ + educator, + followersCount, + avgRating, + isFollowing, + followLoading, + onFollowToggle, + onShare, + isOwnProfile, + stats, +}) => { + return ( +
+
+ +
+
+ + + + {educator?.name?.charAt(0) || "E"} + + +
+ +
+
+
+

+ {educator?.name} +

+ {educator?.role && ( +

{educator.role}

+ )} + {educator?.bio && ( +

+ {educator.bio} +

+ )} + {educator?.createdAt && ( +

+ + Joined{" "} + {format(new Date(educator.createdAt), "MMMM yyyy")} +

+ )} +
+ +
+ + {isOwnProfile ? ( + + ) : ( + + )} +
+
+ +
+
+ + {followersCount} + Followers +
+ {stats && stats.courses > 0 && ( +
+ + {stats.courses} + Courses +
+ )} + {stats && stats.books > 0 && ( +
+ + {stats.books} + Books +
+ )} + {stats && stats.spaces > 0 && ( +
+ + {stats.spaces} + Spaces +
+ )} + {avgRating > 0 && ( +
+ + + {avgRating.toFixed(1)} + + Rating +
+ )} +
+
+
+
+ ); +}; + +export default EducatorProfileHeader;