Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🏗️ Build (push) Failing after 26s
Build & Deploy / 🧪 QA (push) Failing after 1m14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useRef, useState, useId } from 'react';
|
|
import { ComponentShareButton } from './ComponentShareButton';
|
|
|
|
interface BoldNumberProps {
|
|
/** The number to display, e.g. "53%" or "2.5M€" or "-20%" */
|
|
value: string;
|
|
/** Short description of what this number means */
|
|
label: string;
|
|
/** Source attribution */
|
|
source?: string;
|
|
/** Source URL */
|
|
sourceUrl?: string;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Premium hero number component — full-width, dark gradient, animated count-up.
|
|
* Designed for shareable key statistics that stand out in blog posts.
|
|
*/
|
|
export const BoldNumber: React.FC<BoldNumberProps> = ({
|
|
value,
|
|
label,
|
|
source,
|
|
sourceUrl,
|
|
className = '',
|
|
}) => {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [displayValue, setDisplayValue] = useState('');
|
|
const shareId = `boldnum-${useId().replace(/:/g, '')}`;
|
|
|
|
// Extract numeric part for animation
|
|
const numericMatch = value.match(/^([+-]?)(\d+(?:[.,]\d+)?)(.*)/);
|
|
const prefix = numericMatch?.[1] ?? '';
|
|
const numStr = numericMatch?.[2] ?? '';
|
|
const suffix = numericMatch?.[3] ?? value;
|
|
const targetNum = parseFloat(numStr.replace(',', '.')) || 0;
|
|
const hasDecimals = numStr.includes('.') || numStr.includes(',');
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
setIsVisible(true);
|
|
observer.disconnect();
|
|
}
|
|
},
|
|
{ threshold: 0.3 }
|
|
);
|
|
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible || !numStr) {
|
|
setDisplayValue(value);
|
|
return;
|
|
}
|
|
|
|
const duration = 1200;
|
|
const steps = 40;
|
|
const stepTime = duration / steps;
|
|
let step = 0;
|
|
|
|
const timer = setInterval(() => {
|
|
step++;
|
|
const progress = Math.min(step / steps, 1);
|
|
// Ease out cubic
|
|
const eased = 1 - Math.pow(1 - progress, 3);
|
|
const current = targetNum * eased;
|
|
const formatted = hasDecimals ? current.toFixed(1) : Math.round(current).toString();
|
|
setDisplayValue(`${prefix}${formatted}${suffix}`);
|
|
|
|
if (step >= steps) {
|
|
clearInterval(timer);
|
|
setDisplayValue(value);
|
|
}
|
|
}, stepTime);
|
|
|
|
return () => clearInterval(timer);
|
|
}, [isVisible, value, prefix, suffix, targetNum, hasDecimals, numStr]);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={shareId}
|
|
className={`not-prose relative overflow-hidden rounded-2xl my-16 border border-slate-100 bg-slate-50/50 p-10 md:p-14 text-center group ${className}`}
|
|
>
|
|
<div className="absolute top-4 right-4 z-50 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
|
|
<ComponentShareButton targetId={shareId} title={`Statistik: ${label}`} />
|
|
</div>
|
|
|
|
<div className="relative z-10">
|
|
<span className="block text-6xl md:text-8xl font-black tracking-tighter tabular-nums leading-none text-slate-900 pb-2">
|
|
{displayValue || value}
|
|
</span>
|
|
<span className="block mt-4 text-base md:text-lg font-medium text-slate-500 uppercase tracking-widest max-w-lg mx-auto">
|
|
{label}
|
|
</span>
|
|
{source && (
|
|
<span className="block mt-4 text-xs font-semibold text-slate-400">
|
|
{sourceUrl ? (
|
|
<a href={sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 transition-colors">
|
|
Quelle: {source} ↗
|
|
</a>
|
|
) : (
|
|
`Quelle: ${source}`
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|