RTL Meshkee storefront with home/about/contact/products/blog, API auth/contact/blog wiring, and SEO sitemap-config. Co-authored-by: Cursor <cursoragent@cursor.com>
65 lines
1.5 KiB
TypeScript
65 lines
1.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
type Props = {
|
|
value: number;
|
|
className?: string;
|
|
};
|
|
|
|
export function Counter({ value, className = "" }: Props) {
|
|
const [n, setN] = useState(0);
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const started = useRef(false);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el || started.current) return;
|
|
|
|
const run = () => {
|
|
if (started.current) return;
|
|
started.current = true;
|
|
const duration = 1400;
|
|
const start = performance.now();
|
|
const tick = (now: number) => {
|
|
const t = Math.min(1, (now - start) / duration);
|
|
const eased = 1 - Math.pow(1 - t, 3);
|
|
setN(Math.round(value * eased));
|
|
if (t < 1) requestAnimationFrame(tick);
|
|
else setN(value);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
};
|
|
|
|
const isVisible = () => {
|
|
const rect = el.getBoundingClientRect();
|
|
const vh = window.innerHeight || document.documentElement.clientHeight;
|
|
return rect.top < vh * 0.92 && rect.bottom > vh * 0.08;
|
|
};
|
|
|
|
if (isVisible()) {
|
|
run();
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
run();
|
|
observer.disconnect();
|
|
}
|
|
},
|
|
{ threshold: 0.2 },
|
|
);
|
|
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [value]);
|
|
|
|
return (
|
|
<span ref={ref} className={className} dir="ltr">
|
|
{n.toLocaleString("en-US")}
|
|
</span>
|
|
);
|
|
}
|