Animate home stats numbers as count-up 0 to final (Task #25)
The home "إحسانكم لعام 2026" stats numbers now count up from zero to their
final value when the section scrolls into view, like a counter.
New component (artifacts/ehsan-poc/src/components/CountUp.tsx):
- Parses the formatted value string: first space-separated token is the
number, the rest is the suffix (مليون/ألف/مليار / Million/Thousand/Billion).
- Detects the decimal separator present in the token (comma for AR "85,4",
period for "3.113"/EN) and the decimal-digit count; parses to float by
normalizing the separator to ".".
- Uses framer-motion useInView (once, amount 0.3) to trigger a
requestAnimationFrame ease-out animation (~1.6s) from 0 -> target,
re-formatting each frame with the same decimals + separator + suffix.
- Respects useReducedMotion: renders final value immediately.
- Safe fallback: if the token has no parseable number, renders the original
string unchanged.
Wire-up (artifacts/ehsan-poc/src/pages/home.tsx):
- Destructured `language` from useLanguage.
- Replaced raw {value} in the stat number div with
<CountUp key={`${language}-${value}`} value={value} /> so it re-parses and
re-animates on AR<->EN toggle. Color (#14573A), size, layout unchanged.
Verified: tsc --noEmit clean; screenshot shows numbers mid-animation with
correct separators/suffixes in AR (RTL).
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useInView, useReducedMotion } from "framer-motion";
|
||||||
|
|
||||||
|
interface CountUpProps {
|
||||||
|
value: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Parsed {
|
||||||
|
target: number;
|
||||||
|
decimals: number;
|
||||||
|
separator: string;
|
||||||
|
suffix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(value: string): Parsed | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const spaceIdx = trimmed.indexOf(" ");
|
||||||
|
const numberToken = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
|
||||||
|
const suffix = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx);
|
||||||
|
|
||||||
|
const sepMatch = numberToken.match(/[^0-9]/);
|
||||||
|
const separator = sepMatch ? sepMatch[0] : "";
|
||||||
|
const decimals = separator ? numberToken.split(separator)[1]?.length ?? 0 : 0;
|
||||||
|
|
||||||
|
const normalized = separator ? numberToken.replace(separator, ".") : numberToken;
|
||||||
|
const target = parseFloat(normalized);
|
||||||
|
if (!Number.isFinite(target)) return null;
|
||||||
|
|
||||||
|
return { target, decimals, separator, suffix };
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(current: number, { decimals, separator, suffix }: Parsed): string {
|
||||||
|
let num = current.toFixed(decimals);
|
||||||
|
if (decimals > 0 && separator) {
|
||||||
|
num = num.replace(".", separator);
|
||||||
|
}
|
||||||
|
return num + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountUp({ value, durationMs = 1600 }: CountUpProps) {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const inView = useInView(ref, { once: true, amount: 0.3 });
|
||||||
|
const parsed = parseValue(value);
|
||||||
|
|
||||||
|
const [display, setDisplay] = useState<string>(() =>
|
||||||
|
parsed ? format(0, parsed) : value
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!parsed) {
|
||||||
|
setDisplay(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reduce) {
|
||||||
|
setDisplay(format(parsed.target, parsed));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!inView) {
|
||||||
|
setDisplay(format(0, parsed));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
let start = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
if (!start) start = now;
|
||||||
|
const elapsed = now - start;
|
||||||
|
const progress = Math.min(elapsed / durationMs, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
setDisplay(format(parsed.target * eased, parsed));
|
||||||
|
if (progress < 1) {
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
setDisplay(format(parsed.target, parsed));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [inView, reduce, value, durationMs]);
|
||||||
|
|
||||||
|
return <span ref={ref}>{display}</span>;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { OpportunityCard } from "../components/OpportunityCard";
|
import { OpportunityCard } from "../components/OpportunityCard";
|
||||||
import { Reveal } from "../components/Reveal";
|
import { Reveal } from "../components/Reveal";
|
||||||
|
import { CountUp } from "../components/CountUp";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { Pause, Play, Leaf, HandHeart, Users, Wallet } from "lucide-react";
|
import { Pause, Play, Leaf, HandHeart, Users, Wallet } from "lucide-react";
|
||||||
import zakatBanner from "@assets/zakatbanarWEB_1780682994527.png";
|
import zakatBanner from "@assets/zakatbanarWEB_1780682994527.png";
|
||||||
@@ -41,7 +42,7 @@ function Ornament({ className = "" }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { t } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
const { data: published, isLoading: pubLoading } = useListPublishedRequests();
|
const { data: published, isLoading: pubLoading } = useListPublishedRequests();
|
||||||
const [slide, setSlide] = useState(0);
|
const [slide, setSlide] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
@@ -201,7 +202,7 @@ export default function Home() {
|
|||||||
<CardContent className="p-8 text-center">
|
<CardContent className="p-8 text-center">
|
||||||
<Icon className="w-9 h-9 text-primary mx-auto mb-4" aria-hidden="true" />
|
<Icon className="w-9 h-9 text-primary mx-auto mb-4" aria-hidden="true" />
|
||||||
<div className="text-3xl font-bold text-[#14573A] mb-3" data-testid={`stat-value-${i}`}>
|
<div className="text-3xl font-bold text-[#14573A] mb-3" data-testid={`stat-value-${i}`}>
|
||||||
{value}
|
<CountUp key={`${language}-${value}`} value={value} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground font-medium">{label}</div>
|
<div className="text-sm text-muted-foreground font-medium">{label}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user