SEO: render final stat values in SSR (was rendering 0+ to crawlers and OG previewers)
This commit is contained in:
parent
a0863a3e20
commit
9cc2513b63
1 changed files with 20 additions and 6 deletions
|
|
@ -10,12 +10,28 @@ const stats = [
|
||||||
{ value: 5, suffix: "", label: "Industry Verticals", description: "Sports, corporate, financial, aerospace, defense" },
|
{ value: 5, suffix: "", label: "Industry Verticals", description: "Sports, corporate, financial, aerospace, defense" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function formatCount(count: number, value: number) {
|
||||||
|
return count >= 1000 && value >= 1000
|
||||||
|
? `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`
|
||||||
|
: count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function AnimatedNumber({ value, suffix }: { value: number; suffix: string }) {
|
function AnimatedNumber({ value, suffix }: { value: number; suffix: string }) {
|
||||||
const [count, setCount] = useState(0);
|
// Start with the final value so SSR/crawlers/OG-previewers see real numbers.
|
||||||
|
// Animate from 0 -> value only after hydration, when the user can see it.
|
||||||
|
const [count, setCount] = useState<number>(value);
|
||||||
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
const hasAnimated = useRef(false);
|
const hasAnimated = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setHasMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMounted) return;
|
||||||
|
// Reset to 0 only on the client, then count up.
|
||||||
|
setCount(0);
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (entry.isIntersecting && !hasAnimated.current) {
|
if (entry.isIntersecting && !hasAnimated.current) {
|
||||||
|
|
@ -39,13 +55,11 @@ function AnimatedNumber({ value, suffix }: { value: number; suffix: string }) {
|
||||||
);
|
);
|
||||||
if (ref.current) observer.observe(ref.current);
|
if (ref.current) observer.observe(ref.current);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [value]);
|
}, [value, hasMounted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span ref={ref} className="tabular-nums">
|
<span ref={ref} className="tabular-nums">
|
||||||
{count >= 1000 && value >= 1000
|
{formatCount(count, value)}
|
||||||
? `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`
|
|
||||||
: count}
|
|
||||||
{suffix}
|
{suffix}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -53,7 +67,7 @@ function AnimatedNumber({ value, suffix }: { value: number; suffix: string }) {
|
||||||
|
|
||||||
export default function Stats() {
|
export default function Stats() {
|
||||||
return (
|
return (
|
||||||
<section className="py-16 md:py-20 bg-white border-t border-neutral-100">
|
<section className="py-16 md:py-20 bg-white border-t border-neutral-100" aria-label="Practice statistics">
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-10 md:gap-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-10 md:gap-6">
|
||||||
{stats.map((stat, i) => (
|
{stats.map((stat, i) => (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue