-
Notifications
You must be signed in to change notification settings - Fork 73
feat(landing): real social proof, featured-courses carousel, and reduced-motion support #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Whiznificent
wants to merge
3
commits into
Deen-Bridge:dev
Choose a base branch
from
Whiznificent:feat/landing-social-proof
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import Link from "next/link"; | ||
| import { ArrowRight, Sparkles } from "lucide-react"; | ||
| import { fetchCourses } from "@/lib/actions/courses/fetch-courses"; | ||
| import { getAverageRating } from "@/hooks/getAverageRating"; | ||
| import FeaturedCoursesCarousel from "./FeaturedCoursesCarousel"; | ||
| import LandingCourseCard from "./LandingCourseCard"; | ||
|
|
||
| // Cache the rendered subtree for five minutes. `fetchCourses` uses axios | ||
| // (not the Next.js `fetch` API), so we can't tag the request itself with | ||
| // `next.revalidate`. Caching the component closes that gap and stops the | ||
| // landing page from hitting /api/courses on every visitor. | ||
| export const revalidate = 300; | ||
|
|
||
| function rankCourses(courses) { | ||
| if (!Array.isArray(courses) || courses.length === 0) return []; | ||
|
|
||
| return [...courses] | ||
| .filter((c) => c && (c._id || c.id)) | ||
| .map((c) => { | ||
| const enrolled = Array.isArray(c.enrolledUsers) ? c.enrolledUsers.length : 0; | ||
| const rating = getAverageRating(c.reviews); | ||
| const reviewCount = Array.isArray(c.reviews) ? c.reviews.length : 0; | ||
| return { course: c, enrolled, rating, reviewCount }; | ||
| }) | ||
| .sort((a, b) => { | ||
| if (b.enrolled !== a.enrolled) return b.enrolled - a.enrolled; | ||
| if (b.rating !== a.rating) return b.rating - a.rating; | ||
| return b.reviewCount - a.reviewCount; | ||
| }) | ||
| .map((entry) => entry.course); | ||
| } | ||
|
|
||
| /** | ||
| * Server component. Fetches courses from /api/courses via the existing | ||
| * axios-backed `fetchCourses()` action. Sorts by enrollment then rating and | ||
| * surfaces the top items in a client-side embla carousel. Renders nothing | ||
| * (graceful degradation) when the API is unreachable or returns no courses. | ||
| */ | ||
| export default async function FeaturedCourses() { | ||
| let courses = []; | ||
| try { | ||
| courses = await fetchCourses(); | ||
| } catch (error) { | ||
| // eslint-disable-next-line no-console | ||
| console.error("[FeaturedCourses] fetch failed:", error?.message || error); | ||
| courses = []; | ||
| } | ||
|
|
||
| const ranked = rankCourses(courses).slice(0, 8); | ||
|
Whiznificent marked this conversation as resolved.
|
||
|
|
||
| if (ranked.length === 0) { | ||
| // Graceful degradation — silently skip the section. | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <section | ||
| id="featured-courses" | ||
| aria-labelledby="featured-courses-heading" | ||
| className="relative mx-auto w-full max-w-7xl px-4 py-16 sm:py-20" | ||
| > | ||
| <header className="mb-10 flex flex-col items-center text-center sm:flex-row sm:items-end sm:justify-between sm:text-left"> | ||
| <div className="flex flex-col items-center sm:items-start"> | ||
| <span className="mb-3 inline-flex items-center gap-1.5 rounded-full border border-green-200/50 bg-gradient-to-r from-green-50/80 to-emerald-50/80 px-3 py-1 text-xs font-medium text-green-700 dark:border-green-500/20 dark:from-green-500/10 dark:to-emerald-500/10 dark:text-green-300"> | ||
| <Sparkles className="size-3.5" aria-hidden="true" /> | ||
| Featured Courses | ||
| </span> | ||
| <h2 | ||
| id="featured-courses-heading" | ||
| className="bg-gradient-to-r from-accent via-green-500 to-highlight bg-clip-text text-3xl font-bold tracking-tight text-transparent sm:text-4xl md:text-5xl" | ||
| > | ||
| Learn from the Community | ||
| </h2> | ||
| <p className="mt-2 max-w-xl text-base text-muted-foreground sm:text-lg"> | ||
| Hand-picked courses from our most engaged educators and learners. | ||
| </p> | ||
| </div> | ||
|
|
||
| <Link | ||
| href="/dashboard/courses" | ||
| className="mt-4 inline-flex items-center gap-1.5 self-end rounded-full border border-accent/30 px-4 py-2 text-sm font-semibold text-accent transition-colors hover:bg-accent hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 sm:mt-0" | ||
| > | ||
| Browse all courses | ||
| <ArrowRight className="size-4" aria-hidden="true" /> | ||
| </Link> | ||
| </header> | ||
|
|
||
| <FeaturedCoursesCarousel> | ||
| {ranked.map((course) => ( | ||
| <LandingCourseCard key={course._id || course.id} course={course} /> | ||
| ))} | ||
| </FeaturedCoursesCarousel> | ||
| </section> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| "use client"; | ||
|
|
||
| import * as React from "react"; | ||
| import { | ||
| Carousel, | ||
| CarouselContent, | ||
| CarouselItem, | ||
| CarouselNext, | ||
| CarouselPrevious, | ||
| } from "@/components/ui/carousel"; | ||
|
|
||
| /** | ||
| * Thin client-side wrapper around the existing embla-based Carousel primitive. | ||
| * Server-rendered children are passed through; carousel APIs run only on the client. | ||
| */ | ||
| export default function FeaturedCoursesCarousel({ children }) { | ||
| // Normalise to an array; trust upstream keys so React never falls back to | ||
| // an index key (which would mask missing-key bugs in the consumers). | ||
| const slides = React.Children.toArray(children); | ||
|
|
||
| if (slides.length === 0) return null; | ||
|
|
||
| return ( | ||
| <Carousel | ||
| opts={{ | ||
| align: "start", | ||
| skipSnaps: false, | ||
| }} | ||
| className="w-full px-2 sm:px-4" | ||
| > | ||
| <CarouselContent className="-ml-2 sm:-ml-4"> | ||
| {slides.map((child, idx) => ( | ||
| <CarouselItem | ||
| key={child.key ?? `featured-course-${idx}`} | ||
| className="pl-2 sm:pl-4 md:basis-1/2 lg:basis-1/3" | ||
| > | ||
| <div className="h-full pb-2">{child}</div> | ||
| </CarouselItem> | ||
| ))} | ||
| </CarouselContent> | ||
| <CarouselPrevious | ||
| className="hidden sm:flex -left-2 sm:-left-6" | ||
| aria-label="Previous featured course" | ||
| /> | ||
| <CarouselNext | ||
| className="hidden sm:flex -right-2 sm:-right-6" | ||
| aria-label="Next featured course" | ||
| /> | ||
| </Carousel> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import Image from "next/image"; | ||
| import Link from "next/link"; | ||
| import { Star, GraduationCap } from "lucide-react"; | ||
| import { getAverageRating } from "@/hooks/getAverageRating"; | ||
|
|
||
| /** | ||
| * A lightweight, server-renderable course card. | ||
| * Used on the public landing page carousel where there is no auth context. | ||
| * Avoids useAuth / useBookmark hooks so it can render reliably for visitors. | ||
| */ | ||
| export default function LandingCourseCard({ course }) { | ||
| if (!course) return null; | ||
|
|
||
| const { | ||
| _id, | ||
| title = "Untitled course", | ||
| description = "", | ||
| thumbnail, | ||
| price, | ||
| category, | ||
| createdBy, | ||
| reviews = [], | ||
| } = course; | ||
|
Whiznificent marked this conversation as resolved.
|
||
|
|
||
| const rating = getAverageRating(reviews); | ||
| const reviewCount = Array.isArray(reviews) ? reviews.length : 0; | ||
| const priceLabel = | ||
| price === 0 || price === undefined || price === null ? "Free" : `$${price}`; | ||
| const instructorName = createdBy?.name || "DeenBridge Tutor"; | ||
|
|
||
| return ( | ||
| <article className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-green-200/40 bg-white/90 shadow-md backdrop-blur-xl transition-all hover:-translate-y-1 hover:shadow-xl dark:border-white/10 dark:bg-white/5"> | ||
| <div className="relative h-48 w-full overflow-hidden"> | ||
| <Image | ||
| src={thumbnail || "/images/dnb.png"} | ||
| alt={title} | ||
| fill | ||
| sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" | ||
| className="object-cover transition-transform duration-500 group-hover:scale-105" | ||
| loading="lazy" | ||
| /> | ||
| <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" /> | ||
| {category && ( | ||
| <span className="absolute left-3 top-3 z-10 rounded-full bg-white/85 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent shadow"> | ||
| {category} | ||
| </span> | ||
| )} | ||
| </div> | ||
|
|
||
| <div className="flex flex-1 flex-col gap-3 p-5"> | ||
| <h3 className="line-clamp-1 text-lg font-bold text-accent">{title}</h3> | ||
| <p className="line-clamp-2 min-h-[2.5rem] text-sm text-muted-foreground"> | ||
| {description} | ||
| </p> | ||
|
|
||
| <div className="mt-auto flex items-center justify-between gap-3 text-sm"> | ||
| <div className="flex items-center gap-2 text-muted-foreground"> | ||
| <GraduationCap className="size-4 text-accent" aria-hidden="true" /> | ||
| <span className="line-clamp-1 font-medium text-foreground/80"> | ||
| {instructorName} | ||
| </span> | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| {reviewCount > 0 && ( | ||
| <span | ||
| className="flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-800 dark:bg-yellow-500/15 dark:text-yellow-300" | ||
| aria-label={`Rated ${rating.toFixed(1)} out of 5 from ${reviewCount} reviews`} | ||
| > | ||
| <Star className="size-3 fill-yellow-500 text-yellow-500" aria-hidden="true" /> | ||
| {rating.toFixed(1)} | ||
| </span> | ||
| )} | ||
| <span className="rounded-full bg-gradient-to-r from-highlight to-accent px-3 py-1 text-xs font-bold text-white shadow"> | ||
| {priceLabel} | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <Link | ||
| href={`/dashboard/courses/${_id}`} | ||
| className="mt-1 inline-flex w-full items-center justify-center rounded-full bg-accent px-4 py-2 text-sm font-semibold text-white shadow transition-colors hover:bg-highlight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" | ||
| > | ||
| Explore Course | ||
| <span className="sr-only">: {title}</span> | ||
| </Link> | ||
| </div> | ||
| </article> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.