fix(blog): optimize component share logic, typography, and modal layouts
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
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
This commit is contained in:
137
apps/web/src/components/BoldNumber.tsx
Normal file
137
apps/web/src/components/BoldNumber.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
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('');
|
||||
|
||||
// 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]);
|
||||
|
||||
const handleShare = async () => {
|
||||
const shareText = `${value} — ${label}${source ? ` (${source})` : ''}`;
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ text: shareText });
|
||||
} else {
|
||||
await navigator.clipboard.writeText(shareText);
|
||||
}
|
||||
} catch { /* user cancelled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
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 ${className}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Share button - subtle now */}
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="absolute top-4 right-4 z-20 p-2 rounded-lg text-slate-300 hover:text-blue-600 hover:bg-blue-50 transition-all cursor-pointer"
|
||||
title="Teilen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user