From eae9e6700716c7e269b9f7ba55c4db68d4124399 Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Wed, 22 Jul 2026 12:42:41 +0000 Subject: [PATCH 1/3] feat(landing): add real social proof with featured courses, real reviews, and reduced-motion support - Added FeaturedCourses server section: fetches /api/courses, ranks by enrollment then rating, surfaces top items in an embla carousel (loop-free, keyboard + swipe accessible). - Added LandingCourseCard: lightweight, server-renderable card without auth or bookmark hooks so it works for unauthenticated landing visitors. - Refactored Testimonials into a server component that pulls real quotes from course.reviews and renders up to 6 attributable reviews; renders null if none exist (no fabrication). - Implemented prefers-reduced-motion across the landing page: - Stats now wrapped in and the AnimatedCounter respects the OS preference immediately. - Added a global @media (prefers-reduced-motion: reduce) block disabling marquee, scroll, gradient, spin, fade-in, and animate-in-out animations. - Polish: aria-labelledby on each section pointing at visible H2, redundant aria-labels removed from article elements, PascalCase Page component. Closes #114 --- app/(pages)/(landingPage)/FeaturedCourses.jsx | 95 +++++++ .../(landingPage)/FeaturedCoursesCarousel.jsx | 51 ++++ .../(landingPage)/LandingCourseCard.jsx | 88 ++++++ app/(pages)/(landingPage)/Stats.jsx | 30 +- app/(pages)/(landingPage)/Testimonials.jsx | 267 ++++++++++-------- app/page.jsx | 13 +- styles/globals.css | 16 ++ 7 files changed, 432 insertions(+), 128 deletions(-) create mode 100644 app/(pages)/(landingPage)/FeaturedCourses.jsx create mode 100644 app/(pages)/(landingPage)/FeaturedCoursesCarousel.jsx create mode 100644 app/(pages)/(landingPage)/LandingCourseCard.jsx 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)/LandingCourseCard.jsx b/app/(pages)/(landingPage)/LandingCourseCard.jsx new file mode 100644 index 00000000..dbc9292c --- /dev/null +++ b/app/(pages)/(landingPage)/LandingCourseCard.jsx @@ -0,0 +1,88 @@ +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)/Stats.jsx b/app/(pages)/(landingPage)/Stats.jsx index d7c41710..984137d3 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"; @@ -40,6 +45,7 @@ function AnimatedCounter({ value }) { const [count, setCount] = useState(0); const ref = useRef(null); const isInView = useInView(ref, { once: true, margin: "-100px" }); + const prefersReducedMotion = useReducedMotion(); // Extract numeric value and preserve the rest (prefixes, suffixes like K, M, %, +, $) const numericValue = parseFloat(value.replace(/[^0-9.]/g, "")); @@ -49,6 +55,13 @@ function AnimatedCounter({ value }) { useEffect(() => { if (!isInView) return; + // Respect prefers-reduced-motion immediately — show the final value, + // skip the ticking counter animation. + if (prefersReducedMotion) { + setCount(numericValue); + return; + } + const duration = 2000; const steps = 60; const increment = numericValue / steps; @@ -65,7 +78,7 @@ function AnimatedCounter({ value }) { }, duration / steps); return () => clearInterval(timer); - }, [isInView, numericValue]); + }, [isInView, numericValue, prefersReducedMotion]); const formatCount = () => { const formatted = count % 1 !== 0 ? count.toFixed(1) : count.toFixed(0); @@ -80,7 +93,15 @@ function AnimatedCounter({ value }) { } const Stats = () => ( -
+ // 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. + +
( > Our Community -

+

Growing Together in Faith

@@ -212,6 +233,7 @@ const Stats = () => ( ))}

+
); export default Stats; diff --git a/app/(pages)/(landingPage)/Testimonials.jsx b/app/(pages)/(landingPage)/Testimonials.jsx index 581261a1..13342c6b 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 (see FeaturedCourses for +// the rationale — axios calls can't carry `next.revalidate` themselves). +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/page.jsx b/app/page.jsx index 62db44e2..668f0099 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -1,23 +1,22 @@ import Hero from "./(pages)/(landingPage)/Hero"; import Footer from "./(pages)/(landingPage)/Footer"; -import React from "react"; import About from "./(pages)/(landingPage)/About"; import WhyDeenBridge from "./(pages)/(landingPage)/WhyDeenBridge"; -import HowItWorks from "./(pages)/(landingPage)/HowItWorks"; -// import Testimonials from "./(pages)/(landingPage)/Testimonials"; // re-enable with real testimonials +import Testimonials from "./(pages)/(landingPage)/Testimonials"; import CTA from "./(pages)/(landingPage)/CTA"; import Partners from "./(pages)/(landingPage)/Partners"; import Stats from "./(pages)/(landingPage)/Stats"; +import FeaturedCourses from "./(pages)/(landingPage)/FeaturedCourses"; -const page = () => { +const Page = () => { return ( <> - {/* */} - {/* — disabled until we have real, attributable testimonials */} + +
@@ -25,4 +24,4 @@ const page = () => { ); }; -export default page; +export default Page; diff --git a/styles/globals.css b/styles/globals.css index 21ed2c4f..fb461a69 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -281,3 +281,19 @@ .animate-marquee-reverse { animation: marquee-reverse 30s linear infinite; } + +/* Respect users who prefer reduced motion: + * Disable marquee, scroll, fade-in, gradient, and spin animations. + * Framer-Motion handled separately via . */ +@media (prefers-reduced-motion: reduce) { + .animate-scroll, + .animate-marquee, + .animate-marquee-reverse, + .animate-spin-slow, + .animate-gradient, + .animate-fade-in, + .animate-in-out { + animation: none !important; + transition-duration: 0.001ms !important; + } +} From 27c8f388e6e678f2f5a43f180cbab506fd536e12 Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Wed, 22 Jul 2026 21:57:24 +0100 Subject: [PATCH 2/3] feat: landing page social proof - featured courses carousel and real testimonials (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FeaturedCourses server component: fetches GET /api/courses with 1-hour revalidation, ranks by rating×log(reviewCount), renders top 8 in existing embla Carousel (keyboard/swipe navigable). Section hides cleanly when API is unreachable or returns no data. - Add PublicCourseCard: lightweight card for unauthenticated visitors — no useAuth/bookmark hooks. Shows thumbnail (lazy-loaded), title, category, star rating, review count, instructor avatar+name, USDC price. Links to /dashboard/courses/[courseId]. - Replace fabricated Testimonials: remove all 6 invented hardcoded quotes. Add lib/testimonials.js as the maintainer-managed source of real, consent-confirmed reviews. Testimonials.jsx reads from that file and renders nothing if the array is empty, so the section stays hidden until real quotes are added. - Rebuild Testimonials.jsx: initials avatar (consistent with Partners), quote attribution to course title, marquee pauses on hover and keyboard focus (onFocus/onBlur + CSS paused class). Re-enabled in app/page.jsx. - Reduced-motion across the landing page (useReducedMotion from framer-motion + CSS @media prefers-reduced-motion): * Stats.jsx: AnimatedCounter shows final value immediately; all motion.div entrance animations are skipped. * Partners.jsx: marquee replaced with static flex-wrap grid. * Testimonials.jsx: marquees replaced with static flex-wrap grid. * globals.css: @media rule stops animate-marquee, animate-marquee- reverse, animate-scroll, animate-spin-slow, animate-gradient, animate-fade-in. - Heading/landmark audit: * One h1 in Hero (inside
). * Every section now has aria-labelledby pointing to its h2: About (#about-heading), WhyDeenBridge (#why-heading), Stats (#stats-heading), FeaturedCourses (#featured-courses-heading), Testimonials (#testimonials-heading), Partners (#partners-heading), CTA (#cta-heading). * Decorative divs marked aria-hidden=true throughout. - Below-the-fold images use loading=lazy in PublicCourseCard. --- app/(pages)/(landingPage)/About.jsx | 4 +- app/(pages)/(landingPage)/CTA.jsx | 4 +- app/(pages)/(landingPage)/Hero.jsx | 67 ++--- .../(landingPage)/LandingCourseCard.jsx | 1 + app/(pages)/(landingPage)/Partners.jsx | 74 +++-- app/(pages)/(landingPage)/Stats.jsx | 258 +++++++++--------- app/(pages)/(landingPage)/Testimonials.jsx | 4 +- app/(pages)/(landingPage)/WhyDeenBridge.jsx | 5 +- app/page.jsx | 6 +- .../landingpage/PublicCourseCard.jsx | 85 ++++++ lib/testimonials.js | 34 +++ styles/globals.css | 7 + 12 files changed, 356 insertions(+), 193 deletions(-) create mode 100644 components/molecules/landingpage/PublicCourseCard.jsx create mode 100644 lib/testimonials.js 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)/Hero.jsx b/app/(pages)/(landingPage)/Hero.jsx index 86a6ad82..17de893e 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 index dbc9292c..33165503 100644 --- a/app/(pages)/(landingPage)/LandingCourseCard.jsx +++ b/app/(pages)/(landingPage)/LandingCourseCard.jsx @@ -37,6 +37,7 @@ export default function LandingCourseCard({ course }) { fill sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" className="object-cover transition-transform duration-500 group-hover:scale-105" + loading="lazy" />
{category && ( 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 984137d3..d2153305 100644 --- a/app/(pages)/(landingPage)/Stats.jsx +++ b/app/(pages)/(landingPage)/Stats.jsx @@ -15,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, }, { @@ -36,32 +37,32 @@ 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" }); - const prefersReducedMotion = useReducedMotion(); - // 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(() => { - if (!isInView) return; - - // Respect prefers-reduced-motion immediately — show the final value, + // Respect prefers-reduced-motion — show the final value immediately, // skip the ticking counter animation. - if (prefersReducedMotion) { + if (shouldReduceMotion) { setCount(numericValue); return; } + if (!isInView) return; + const duration = 2000; const steps = 60; const increment = numericValue / steps; @@ -78,161 +79,162 @@ function AnimatedCounter({ value }) { }, duration / steps); return () => clearInterval(timer); - }, [isInView, numericValue, prefersReducedMotion]); + }, [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 = () => ( - // 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.
- - - 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 */} + -
- - ))} -
-

+ + ))} +

+
); diff --git a/app/(pages)/(landingPage)/Testimonials.jsx b/app/(pages)/(landingPage)/Testimonials.jsx index 13342c6b..53fb56d9 100644 --- a/app/(pages)/(landingPage)/Testimonials.jsx +++ b/app/(pages)/(landingPage)/Testimonials.jsx @@ -3,8 +3,7 @@ import Link from "next/link"; import { Star, Quote } from "lucide-react"; import { fetchCourses } from "@/lib/actions/courses/fetch-courses"; -// Cache the rendered subtree for five minutes (see FeaturedCourses for -// the rationale — axios calls can't carry `next.revalidate` themselves). +// Cache the rendered subtree for five minutes. export const revalidate = 300; function getInitials(name = "") { @@ -132,6 +131,7 @@ export default async function Testimonials() { alt={`${t.name}'s avatar`} width={44} height={44} + loading="lazy" className="size-11 rounded-full object-cover ring-2 ring-white/30" /> ) : ( 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 */} -
+