diff --git a/app/(pages)/(landingPage)/About.jsx b/app/(pages)/(landingPage)/About.jsx index b59dd67a..57a2e21d 100644 --- a/app/(pages)/(landingPage)/About.jsx +++ b/app/(pages)/(landingPage)/About.jsx @@ -5,13 +5,13 @@ import Button from "@/components/atoms/form/Button"; const About = () => { return ( -
+
{/* Left Section - Text Content */}
-

+

Where Deen Meets{" "} Excellence

diff --git a/app/(pages)/(landingPage)/CTA.jsx b/app/(pages)/(landingPage)/CTA.jsx index 166ff62b..fa96e772 100644 --- a/app/(pages)/(landingPage)/CTA.jsx +++ b/app/(pages)/(landingPage)/CTA.jsx @@ -3,8 +3,8 @@ import Link from "next/link"; export default function CTA() { return ( -
-

+
+

Ready to Join Deen Bridge?

diff --git a/app/(pages)/(landingPage)/FeaturedCourses.jsx b/app/(pages)/(landingPage)/FeaturedCourses.jsx new file mode 100644 index 00000000..54c1a4db --- /dev/null +++ b/app/(pages)/(landingPage)/FeaturedCourses.jsx @@ -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); + + if (ranked.length === 0) { + // Graceful degradation — silently skip the section. + return null; + } + + return ( +

+ ); +} diff --git a/app/(pages)/(landingPage)/FeaturedCoursesCarousel.jsx b/app/(pages)/(landingPage)/FeaturedCoursesCarousel.jsx new file mode 100644 index 00000000..bc74900a --- /dev/null +++ b/app/(pages)/(landingPage)/FeaturedCoursesCarousel.jsx @@ -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 ( + + + {slides.map((child, idx) => ( + +
{child}
+
+ ))} +
+ + +
+ ); +} diff --git a/app/(pages)/(landingPage)/Hero.jsx b/app/(pages)/(landingPage)/Hero.jsx index 86a6ad82..d061a0f2 100644 --- a/app/(pages)/(landingPage)/Hero.jsx +++ b/app/(pages)/(landingPage)/Hero.jsx @@ -6,39 +6,42 @@ import Button from "@/components/atoms/form/Button"; import Navbar from "@/components/molecules/ladingpage/Navbar"; const Hero = () => { - return ( -
-
- -
-

- Welcome to{" "} - - Deen Bridge - -

+ return ( +
+
- ); +

+ Your journey to{" "} + excellence starts + here. +

+ +
+
+ ); }; export default Hero; diff --git a/app/(pages)/(landingPage)/LandingCourseCard.jsx b/app/(pages)/(landingPage)/LandingCourseCard.jsx new file mode 100644 index 00000000..33165503 --- /dev/null +++ b/app/(pages)/(landingPage)/LandingCourseCard.jsx @@ -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; + + 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 ( +
+
+ {title} +
+ {category && ( + + {category} + + )} +
+ +
+

{title}

+

+ {description} +

+ +
+
+
+
+ {reviewCount > 0 && ( + + + )} + + {priceLabel} + +
+
+ + + Explore Course + : {title} + +
+
+ ); +} diff --git a/app/(pages)/(landingPage)/Partners.jsx b/app/(pages)/(landingPage)/Partners.jsx index e33893f5..57f7255c 100644 --- a/app/(pages)/(landingPage)/Partners.jsx +++ b/app/(pages)/(landingPage)/Partners.jsx @@ -1,5 +1,6 @@ "use client"; +import { useReducedMotion } from "framer-motion"; import { partners } from "@/lib/data"; function initials(name) { @@ -10,10 +11,35 @@ function initials(name) { .join(""); } +function PartnerPill({ partner, index }) { + return ( +
+ + {initials(partner.name)} + + + {partner.name} + +
+ ); +} + export default function Partners() { + const shouldReduceMotion = useReducedMotion(); + return ( -
-

+
+

Trusted Voices in the Ummah

@@ -21,28 +47,32 @@ export default function Partners() { leading the way in education, community, and service worldwide.

- {/* Auto-scrolling marquee */} -
-
-
- -
- {[...partners, ...partners].map((partner, index) => ( -
- - {initials(partner.name)} - - - {partner.name} - -
+ {/* Reduced-motion: static wrapping grid */} + {shouldReduceMotion ? ( +
+ {partners.map((partner, index) => ( + ))}
-
+ ) : ( + /* Auto-scrolling marquee */ +
+
); } diff --git a/app/(pages)/(landingPage)/Stats.jsx b/app/(pages)/(landingPage)/Stats.jsx index d7c41710..d2153305 100644 --- a/app/(pages)/(landingPage)/Stats.jsx +++ b/app/(pages)/(landingPage)/Stats.jsx @@ -1,6 +1,11 @@ "use client"; -import { motion, useInView } from "framer-motion"; +import { + motion, + MotionConfig, + useInView, + useReducedMotion, +} from "framer-motion"; import { useRef, useEffect, useState } from "react"; import { TrendingUp, Users, DollarSign, CheckCircle2 } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -10,7 +15,8 @@ const stats = [ k: "100%", v: "Non-Custodial Payments", icon: CheckCircle2, - description: "You sign every transaction in your own wallet — we never hold your funds", + description: + "You sign every transaction in your own wallet — we never hold your funds", isDark: true, }, { @@ -31,22 +37,30 @@ const stats = [ k: "3", v: "Open-Source Services", icon: Users, - description: "Web app, API, and AI — all MIT-licensed and built in the open", + description: + "Web app, API, and AI — all MIT-licensed and built in the open", isDark: true, }, ]; function AnimatedCounter({ value }) { + const shouldReduceMotion = useReducedMotion(); const [count, setCount] = useState(0); const ref = useRef(null); const isInView = useInView(ref, { once: true, margin: "-100px" }); - // Extract numeric value and preserve the rest (prefixes, suffixes like K, M, %, +, $) const numericValue = parseFloat(value.replace(/[^0-9.]/g, "")); - const prefix = value.match(/^[^0-9.]*/)?.[0] || ""; + const prefix = value.match(/^[^0-9.]*/)?.[0] ?? ""; const suffix = value.replace(/[0-9.]/g, "").replace(prefix, ""); useEffect(() => { + // Respect prefers-reduced-motion — show the final value immediately, + // skip the ticking counter animation. + if (shouldReduceMotion) { + setCount(numericValue); + return; + } + if (!isInView) return; const duration = 2000; @@ -65,153 +79,163 @@ function AnimatedCounter({ value }) { }, duration / steps); return () => clearInterval(timer); - }, [isInView, numericValue]); + }, [isInView, numericValue, shouldReduceMotion]); const formatCount = () => { - const formatted = count % 1 !== 0 ? count.toFixed(1) : count.toFixed(0); + const formatted = + count % 1 !== 0 ? count.toFixed(1) : count.toFixed(0); return `${prefix}${formatted}${suffix}`; }; return ( - {isInView ? formatCount() : `${prefix}0${suffix}`} + {shouldReduceMotion ? value : isInView ? formatCount() : `${prefix}0${suffix}`} ); } +// MotionConfig reducedMotion="user" makes framer-motion honor the +// OS-level prefers-reduced-motion setting: entrance animations and the +// spring counter stop playing and snap to their final state. const Stats = () => ( -
- +
- - Our Community - -

- Growing Together in Faith -

-

- Numbers that reflect our commitment to connecting Muslims worldwide through - authentic knowledge, meaningful conversations, and spiritual growth -

- - -
- {stats.map((s, i) => ( - + Our Community + +

-
+

+ Numbers that reflect our commitment to connecting Muslims worldwide through + authentic knowledge, meaningful conversations, and spiritual growth +

+ + +
+ {stats.map((s, i) => ( + - {/* Grid pattern overlay */}
- -
- {/* Icon in top-left */} - + {/* Grid pattern overlay */} + -
- - ))} -
-

+
+ ))} +

+
+ ); export default Stats; diff --git a/app/(pages)/(landingPage)/Testimonials.jsx b/app/(pages)/(landingPage)/Testimonials.jsx index 581261a1..53fb56d9 100644 --- a/app/(pages)/(landingPage)/Testimonials.jsx +++ b/app/(pages)/(landingPage)/Testimonials.jsx @@ -1,135 +1,168 @@ -"use client"; import Image from "next/image"; -import { cn } from "@/lib/utils"; -import { poppins_600 } from "@/lib/config/font.config"; +import Link from "next/link"; +import { Star, Quote } from "lucide-react"; +import { fetchCourses } from "@/lib/actions/courses/fetch-courses"; -const testimonials = [ - { - name: "Zahra Yusuf", - role: "Student, Nigeria", - avatar: "/images/img-9.jpeg", - quote: - "Deen Bridge has helped me connect with sisters around the world and deepen my understanding of Islam. The community is so welcoming!", - }, - { - name: "Ustadh Ahmad", - role: "Scholar, Egypt", - avatar: "/images/img-10.jpg", - quote: - "I love how Deen Bridge makes authentic knowledge accessible to everyone. The platform is beautifully designed and easy to use.", - }, - { - name: "Zayd Khan", - role: "Entrepreneur, UK", - avatar: "/images/img-11.jpg", - quote: - "The spaces and events feature is a game changer. I've met so many inspiring Muslims and learned so much!", - }, - { - name: "Maryam Abubakar", - role: "Book Lover, Malaysia", - avatar: "/images/img-12.jpg", - quote: - "The book recommendations are spot on. I've discovered gems I never would have found elsewhere!", - }, - { - name: "Imam Suleiman", - role: "Imam, USA", - avatar: "/images/img-13.jpg", - quote: - "Deen Bridge is a blessing for our Ummah. It's secure, supportive, and truly brings people together for good.", - }, - { - name: "Aisha Bello", - role: "Mother, South Africa", - avatar: "/images/img-14.jpg", - quote: - "My children and I use Deen Bridge to learn and grow together. The resources are trustworthy and engaging!", - }, -]; +// Cache the rendered subtree for five minutes. +export const revalidate = 300; -// Split testimonials into two rows -const row1 = testimonials.slice(0, 3); -const row2 = testimonials.slice(3); +function getInitials(name = "") { + return name + .split(/\s+/) + .filter((w) => /^[A-Za-z]/.test(w)) + .slice(0, 2) + .map((w) => w[0].toUpperCase()) + .join(""); +} + +function extractRealReviews(courses) { + if (!Array.isArray(courses)) return []; + const reviews = []; + for (const course of courses) { + if (!course || !Array.isArray(course.reviews)) continue; + for (const review of course.reviews) { + if (!review) continue; + const comment = (review.comment || "").trim(); + const userName = review.user?.name?.trim(); + // Skip entries without both an attributable user and a meaningful comment + if (!comment || !userName) continue; + reviews.push({ + id: review._id || `${course._id}-${reviews.length}`, + name: userName, + avatar: review.user?.avatar, + rating: typeof review.rating === "number" ? review.rating : 0, + quote: comment, + course: { + _id: course._id, + title: course.title || "this course", + }, + }); + } + } + return reviews; +} + +/** + * Server component. Builds a real, attributable testimonials section from + * actual student reviews attached to courses. If the API is unreachable or + * no real reviews exist yet, renders nothing (graceful degradation — + * acceptance criteria for issue #114 explicitly forbid fabricating quotes). + */ +export default async function Testimonials() { + let courses = []; + try { + courses = await fetchCourses(); + } catch (error) { + // eslint-disable-next-line no-console + console.error("[Testimonials] fetch failed:", error?.message || error); + courses = []; + } -// Duplicate for seamless loop -const row1Duplicated = [...row1, ...row1]; -const row2Duplicated = [...row2, ...row2]; + const reviews = extractRealReviews(courses).slice(0, 6); + if (reviews.length === 0) { + // Nothing real to show — silently skip the section. + return null; + } -export default function Testimonials() { return ( -
- {/* Decorative Islamic motif background */} -
-
-
+
+
); diff --git a/app/(pages)/(landingPage)/WhyDeenBridge.jsx b/app/(pages)/(landingPage)/WhyDeenBridge.jsx index 0a2e26c0..a102f925 100644 --- a/app/(pages)/(landingPage)/WhyDeenBridge.jsx +++ b/app/(pages)/(landingPage)/WhyDeenBridge.jsx @@ -46,14 +46,15 @@ const features = [ export default function WhyDeenBridge() { return ( -
+
{/* Decorative Islamic motif background */} -
+