120 lines
3.2 KiB
TypeScript
120 lines
3.2 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import CountUp from 'react-countup';
|
|
|
|
interface CounterCardProps {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
value: number;
|
|
suffix?: string;
|
|
trend?: number;
|
|
color?: string;
|
|
}
|
|
|
|
const CounterCard: React.FC<CounterCardProps> = ({
|
|
icon,
|
|
title,
|
|
value,
|
|
suffix = '',
|
|
trend,
|
|
color = '#F84525'
|
|
}) => {
|
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!('IntersectionObserver' in window)) return;
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
(entry.target as Element).classList.add('animate-fade-in-up');
|
|
}
|
|
});
|
|
},
|
|
{ threshold: 0.1 }
|
|
);
|
|
|
|
try {
|
|
if (cardRef.current && cardRef.current instanceof Element) {
|
|
observer.observe(cardRef.current);
|
|
}
|
|
} catch (e) {
|
|
console.warn('IntersectionObserver.observe failed:', e);
|
|
}
|
|
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
className="card-enhanced widget-card position-relative overflow-hidden"
|
|
style={{ minHeight: '140px' }}
|
|
>
|
|
<div className="card-body p-4">
|
|
<div className="d-flex align-items-center justify-content-between">
|
|
<div className="flex-grow-1">
|
|
<div className="d-flex align-items-center mb-2">
|
|
<div
|
|
className="rounded-circle p-2 me-3"
|
|
style={{ backgroundColor: `${color}20`, color: color }}
|
|
>
|
|
{icon}
|
|
</div>
|
|
<h6 className="text-muted mb-0 small">{title}</h6>
|
|
</div>
|
|
|
|
<div className="d-flex align-items-center">
|
|
<h3 className="mb-0 fw-bold me-2" style={{ color: color }}>
|
|
<CountUp
|
|
start={0}
|
|
end={value}
|
|
duration={2.5}
|
|
separator=","
|
|
suffix={suffix}
|
|
/>
|
|
</h3>
|
|
|
|
{trend && (
|
|
<span
|
|
className={`badge ${trend > 0 ? 'bg-success' : 'bg-danger'} d-flex align-items-center`}
|
|
style={{ fontSize: '10px' }}
|
|
>
|
|
<i className={`bi bi-arrow-${trend > 0 ? 'up' : 'down'} me-1`}></i>
|
|
{Math.abs(trend)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="rounded-circle d-flex align-items-center justify-content-center"
|
|
style={{
|
|
width: '60px',
|
|
height: '60px',
|
|
backgroundColor: `${color}10`,
|
|
color: color
|
|
}}
|
|
>
|
|
{icon}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Animated background element */}
|
|
<div
|
|
className="position-absolute"
|
|
style={{
|
|
bottom: '-20px',
|
|
right: '-20px',
|
|
width: '80px',
|
|
height: '80px',
|
|
borderRadius: '50%',
|
|
backgroundColor: `${color}08`,
|
|
opacity: 0.3
|
|
}}
|
|
></div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CounterCard; |