Files
mintel.me/apps/web/src/components/BoldNumber.tsx
Marc Mintel 38f2cc8b85
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
feat(ui): enhance component share UX and add new interactive simulations
2026-02-22 16:58:42 +01:00

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>
);
};