Files
mintel.me/apps/web/src/components/StatsGrid.tsx
Marc Mintel b15c8408ff
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🧪 QA (push) Failing after 1m48s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
fix(blog): optimize component share logic, typography, and modal layouts
2026-02-22 11:41:28 +01:00

149 lines
6.1 KiB
TypeScript

'use client';
import React, { useEffect, useRef, useState } from 'react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface StatItem {
value: string;
label: string;
subtext?: string;
}
interface StatsGridProps {
/**
* Pipe-delimited stats. Each stat: "value|label|subtext" separated by ~
* Example: "53%|Mehr Umsatz|Rakuten 24~33%|Conversion Boost|nach CWV Fix"
*/
stats: string;
className?: string;
}
function parseStats(raw: string): StatItem[] {
return raw
.split('~')
.map(s => s.trim())
.filter(Boolean)
.map(s => {
const [value = '', label = '', subtext] = s.split('|').map(p => p.trim());
return { value, label, subtext };
});
}
function AnimatedValue({ value, isVisible }: { value: string; isVisible: boolean }) {
const [display, setDisplay] = useState('');
const numMatch = value.match(/^([+-]?)(\d+(?:[.,]\d+)?)(.*)/);
const prefix = numMatch?.[1] ?? '';
const numStr = numMatch?.[2] ?? '';
const suffix = numMatch?.[3] ?? value;
const target = parseFloat(numStr.replace(',', '.')) || 0;
const hasDecimals = numStr.includes('.') || numStr.includes(',');
useEffect(() => {
if (!isVisible || !numStr) {
setDisplay(value);
return;
}
const duration = 1500;
const steps = 60;
const stepTime = duration / steps;
let step = 0;
const timer = setInterval(() => {
step++;
const progress = Math.min(step / steps, 1);
// Ease out expo
const eased = 1 - Math.pow(2, -10 * progress);
const current = target * eased;
const formatted = hasDecimals ? current.toFixed(1) : Math.round(current).toString();
setDisplay(`${prefix}${formatted}${suffix}`);
if (step >= steps) {
clearInterval(timer);
setDisplay(value);
}
}, stepTime);
return () => clearInterval(timer);
}, [isVisible, value, prefix, suffix, target, hasDecimals, numStr]);
return <>{display || value}</>;
}
const gradients = [
'from-blue-500/10 to-indigo-500/5',
'from-emerald-500/10 to-teal-500/5',
'from-violet-500/10 to-purple-500/5',
'from-amber-500/10 to-orange-500/5',
];
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, className = '' }) => {
if (!stats || typeof stats !== 'string') return null;
const items = parseStats(stats);
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const shareId = `statsgrid-${React.useId().replace(/:/g, "")}`;
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const cols = (items?.length || 0) <= 2 ? 'grid-cols-2' : (items?.length || 0) === 3 ? 'grid-cols-3' : 'grid-cols-2 md:grid-cols-4';
return (
<Reveal direction="up" delay={0.1}>
<div ref={ref} id={shareId} className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}>
{/* Ambient Glow for the entire grid */}
<div className="absolute -inset-2 bg-gradient-to-br from-slate-100/50 to-white/30 rounded-[2.5rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
{/* Main Grid Container */}
<div className="glass bg-white/60 backdrop-blur-xl border border-slate-100 rounded-3xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden p-3 md:p-4">
{/* Share Button top right */}
<div className="absolute top-4 right-4 md:top-6 md:right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title="Performance Stats Grid" />
</div>
<div className={`grid ${cols} gap-3 md:gap-4 mt-8 md:mt-0`}>
{items.map((item, i) => (
<div
key={i}
className={`group/item relative flex flex-col items-center justify-center p-6 md:p-8 bg-gradient-to-br ${gradients[i % gradients.length]} border border-slate-100/50 rounded-2xl text-center transition-all duration-500 hover:bg-white hover:border-slate-200 hover:shadow-sm`}
>
<span className={`text-3xl md:text-4xl lg:text-5xl font-black text-slate-900 tracking-tighter tabular-nums leading-none transition-transform duration-500 group-hover/item:scale-110`}>
<AnimatedValue value={item.value} isVisible={isVisible} />
</span>
<span className="text-[10px] md:text-xs font-black text-slate-500 mt-3 uppercase tracking-[0.2em] leading-tight">
{item.label}
</span>
{item.subtext && (
<span className="text-[9px] md:text-[10px] font-bold text-slate-400 mt-1.5 leading-snug">
{item.subtext}
</span>
)}
{/* Item-specific ambient glow on hover */}
<div className="absolute inset-0 bg-white/0 group-hover/item:bg-white/10 transition-colors pointer-events-none rounded-2xl" />
</div>
))}
</div>
</div>
</div>
</Reveal>
);
};