diff --git a/artifacts/ehsan-poc/src/components/CountUp.tsx b/artifacts/ehsan-poc/src/components/CountUp.tsx new file mode 100644 index 0000000..ee7d0a7 --- /dev/null +++ b/artifacts/ehsan-poc/src/components/CountUp.tsx @@ -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(null); + const inView = useInView(ref, { once: true, amount: 0.3 }); + const parsed = parseValue(value); + + const [display, setDisplay] = useState(() => + 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 {display}; +} diff --git a/artifacts/ehsan-poc/src/pages/home.tsx b/artifacts/ehsan-poc/src/pages/home.tsx index 0371b88..da0bb88 100644 --- a/artifacts/ehsan-poc/src/pages/home.tsx +++ b/artifacts/ehsan-poc/src/pages/home.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { OpportunityCard } from "../components/OpportunityCard"; import { Reveal } from "../components/Reveal"; +import { CountUp } from "../components/CountUp"; import { Link } from "wouter"; import { Pause, Play, Leaf, HandHeart, Users, Wallet } from "lucide-react"; import zakatBanner from "@assets/zakatbanarWEB_1780682994527.png"; @@ -41,7 +42,7 @@ function Ornament({ className = "" }: { className?: string }) { } export default function Home() { - const { t } = useLanguage(); + const { t, language } = useLanguage(); const { data: published, isLoading: pubLoading } = useListPublishedRequests(); const [slide, setSlide] = useState(0); const [paused, setPaused] = useState(false); @@ -201,7 +202,7 @@ export default function Home() {