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
149 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
};
|