Replace home hero with animated EHSAN banner carousel

Task #6: On artifacts/ehsan-poc home page, replaced the green text hero
with an auto-rotating, looping carousel of the four official ehsan.sa
banner images (Zakat, recurring/dawri, Waqf, gift) imported via @assets.

- Auto-advances every 5s, loops, and respects prefers-reduced-motion.
- Clickable dot indicators plus an accessible pause/play toggle (WCAG
  2.2.2) on a translucent backdrop pill.
- Bottom gradient scrim keeps the white CTA + controls readable on all
  four banner backgrounds.
- Removed the overlay heading, subtitle, and the "طلب دعم" button per the
  user; kept the "تصفّح الفرص" CTA linking to /opportunities.
- Added common.pause / common.play i18n keys (AR + EN) for the toggle.

Banners use alt="" (decorative) since the functional CTA is separately
labeled. Verified with tsc --noEmit and home-page screenshots (RTL).
This commit is contained in:
Replit Agent
2026-06-05 18:20:34 +00:00
parent 2828a816ce
commit d371d63c19
2 changed files with 82 additions and 59 deletions
@@ -28,6 +28,8 @@ export const en = {
login: "Login", login: "Login",
logout: "Logout", logout: "Logout",
cart: "Cart", cart: "Cart",
pause: "Pause slideshow",
play: "Play slideshow",
}, },
nav: { nav: {
home: "Home", home: "Home",
@@ -238,6 +240,8 @@ export const ar = {
login: "تسجيل الدخول", login: "تسجيل الدخول",
logout: "تسجيل الخروج", logout: "تسجيل الخروج",
cart: "السلة", cart: "السلة",
pause: "إيقاف العرض",
play: "تشغيل العرض",
}, },
nav: { nav: {
home: "الرئيسية", home: "الرئيسية",
+69 -50
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useLanguage } from "../contexts/LanguageContext"; import { useLanguage } from "../contexts/LanguageContext";
import { useGetStats, useListPublishedRequests } from "@workspace/api-client-react"; import { useGetStats, useListPublishedRequests } from "@workspace/api-client-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -8,13 +8,30 @@ import { Skeleton } from "@/components/ui/skeleton";
import { OpportunityCard } from "../components/OpportunityCard"; import { OpportunityCard } from "../components/OpportunityCard";
import { Riyal } from "@/components/Riyal"; import { Riyal } from "@/components/Riyal";
import { Link } from "wouter"; import { Link } from "wouter";
import { Search } from "lucide-react"; import { Search, Pause, Play } from "lucide-react";
import zakatBanner from "@assets/zakatbanarWEB_1780682994527.png";
import dawriBanner from "@assets/dawribanarWEB_1780682998494.png";
import waqfBanner from "@assets/waqfbanerWEB_1780683002610.png";
import giftBanner from "@assets/giftbanarWEB_1780683006569.png";
const HERO_BANNERS = [zakatBanner, dawriBanner, waqfBanner, giftBanner];
export default function Home() { export default function Home() {
const { t } = useLanguage(); const { t } = useLanguage();
const { data: stats, isLoading: statsLoading } = useGetStats(); const { data: stats, isLoading: statsLoading } = useGetStats();
const { data: published, isLoading: pubLoading } = useListPublishedRequests(); const { data: published, isLoading: pubLoading } = useListPublishedRequests();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [slide, setSlide] = useState(0);
const [paused, setPaused] = useState(false);
useEffect(() => {
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduce || paused) return;
const id = setInterval(() => {
setSlide((s) => (s + 1) % HERO_BANNERS.length);
}, 5000);
return () => clearInterval(id);
}, [paused]);
const filtered = (published || []).filter((r) => { const filtered = (published || []).filter((r) => {
if (!query.trim()) return true; if (!query.trim()) return true;
@@ -28,64 +45,66 @@ export default function Home() {
return ( return (
<> <>
{/* Hero — official EHSAN green banner */} {/* Hero — auto-rotating banner carousel */}
<section className="relative overflow-hidden bg-primary text-primary-foreground"> <section
<div className="relative w-full overflow-hidden bg-muted"
className="absolute inset-0 opacity-25" aria-roledescription="carousel"
style={{ aria-label={t.home.heroBrowse}
background: >
"radial-gradient(circle at 85% 20%, rgba(255,255,255,0.18) 0, transparent 45%), radial-gradient(circle at 10% 90%, rgba(0,0,0,0.18) 0, transparent 50%)", <div className="relative h-[220px] sm:h-[320px] md:h-[420px] lg:h-[480px]">
}} {HERO_BANNERS.map((src, i) => (
<img
key={i}
src={src}
alt=""
aria-hidden="true"
loading={i === 0 ? "eager" : "lazy"}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ease-in-out ${
i === slide ? "opacity-100" : "opacity-0"
}`}
/> />
{/* decorative leaves (visual only) */} ))}
<svg
className="pointer-events-none absolute -bottom-10 start-0 h-56 w-56 text-white/10"
viewBox="0 0 200 200"
fill="currentColor"
aria-hidden="true"
>
<path d="M100 10C60 40 40 90 60 140c30-20 60-50 80-100C120 60 100 90 90 120c-5-40 0-80 10-110z" />
</svg>
<svg
className="pointer-events-none absolute -top-12 end-8 h-72 w-72 text-white/10 rotate-180"
viewBox="0 0 200 200"
fill="currentColor"
aria-hidden="true"
>
<path d="M100 10C60 40 40 90 60 140c30-20 60-50 80-100C120 60 100 90 90 120c-5-40 0-80 10-110z" />
</svg>
<div className="container mx-auto px-4 py-20 md:py-24 relative"> {/* bottom scrim keeps the CTA + dots readable over any banner */}
<div className="max-w-2xl"> <div className="pointer-events-none absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/45 to-transparent" />
<span className="inline-block rounded-full bg-white/15 px-4 py-1 text-sm font-medium mb-5">
{t.home.heroBadge} <div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-4 md:p-8">
</span> <Link href="/opportunities" className="pointer-events-auto">
<h1 className="text-3xl md:text-5xl font-bold leading-tight mb-5">
{t.home.heroTitle}
</h1>
<p className="text-lg md:text-xl text-primary-foreground/90 mb-8 leading-relaxed">
{t.home.heroSubtitle}
</p>
<div className="flex flex-wrap gap-3">
<Link href="/opportunities">
<Button <Button
size="lg" size="lg"
className="bg-white text-primary hover:bg-white/90 font-bold px-7" className="bg-white text-primary hover:bg-white/90 font-bold px-7 shadow-lg"
data-testid="button-heroBrowse" data-testid="button-heroBrowse"
> >
{t.home.heroBrowse} {t.home.heroBrowse}
</Button> </Button>
</Link> </Link>
<Link href="/request">
<Button <div className="pointer-events-auto flex items-center gap-3 rounded-full bg-black/25 px-3 py-2 backdrop-blur-sm">
size="lg" <button
variant="outline" type="button"
className="border-white/70 bg-transparent text-white hover:bg-white/10 font-bold px-7" onClick={() => setPaused((p) => !p)}
data-testid="button-heroRequest" aria-label={paused ? t.common.play : t.common.pause}
aria-pressed={paused}
data-testid="button-hero-toggle"
className="flex h-5 w-5 items-center justify-center text-white/90 transition-colors hover:text-white"
> >
{t.nav.requestSupport} {paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
</Button> </button>
</Link> <div className="flex items-center gap-2">
{HERO_BANNERS.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setSlide(i)}
aria-label={`${t.home.heroBrowse} ${i + 1}`}
aria-current={i === slide}
data-testid={`dot-hero-${i}`}
className={`h-2.5 rounded-full transition-all duration-300 ${
i === slide ? "w-7 bg-white" : "w-2.5 bg-white/50 hover:bg-white/80"
}`}
/>
))}
</div>
</div> </div>
</div> </div>
</div> </div>