mirror of
https://github.com/complexcaresolutions/frontend.blogwoman.de.git
synced 2026-03-17 15:04:01 +00:00
feat: complete StatsBlock with all CMS features
Replaces basic stub with full-featured component supporting: - Count-up animation (viewport/immediate trigger, stagger) - Inline SVG icons for all 14 CMS icon options - All layout modes (row, grid, cards, column, inline, feature) - Card styling (border, shadow, hover effects) - Value size/weight customization - Background variants (light, dark, primary, gradient) - Dividers between stats - Icon positioning (top, left, inline) and alignment - Decorative underlines with scroll-reveal animation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3812592cdb
commit
46034873b6
1 changed files with 331 additions and 29 deletions
|
|
@ -1,36 +1,108 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BlockByType } from '@c2s/payload-contracts/types'
|
||||
|
||||
type StatsBlockProps = Omit<BlockByType<'stats-block'>, 'blockType' | 'blockName'>
|
||||
type StatsBlockData = Omit<BlockByType<'stats-block'>, 'blockType' | 'blockName'>
|
||||
|
||||
export function StatsBlock({
|
||||
title,
|
||||
subtitle,
|
||||
stats,
|
||||
style,
|
||||
layout = 'row',
|
||||
}: StatsBlockProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
// Inline SVG icons (no external icon library)
|
||||
const iconPaths: Record<string, string> = {
|
||||
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
|
||||
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z',
|
||||
heart: 'M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z',
|
||||
check: 'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3',
|
||||
trophy: 'M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z',
|
||||
chart: 'M3 3v18h18M18 17V9M13 17V5M8 17v-3',
|
||||
clock: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM12 6v6l4 2',
|
||||
calendar: 'M16 2v4M8 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z',
|
||||
globe: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z',
|
||||
building: 'M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18ZM6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2M10 6h4M10 10h4M10 14h4M10 18h4',
|
||||
document: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8ZM14 2v6h6M16 13H8M16 17H8M10 9H8',
|
||||
target: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12ZM12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z',
|
||||
rocket: 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09ZM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2ZM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5',
|
||||
handshake: 'M11 17a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1ZM2 6h4M18 6h4M11 3H7a2 2 0 0 0-2 2v4l3 3 2.5-2.5M13 3h4a2 2 0 0 1 2 2v4l-3 3-2.5-2.5M14 14l1.5-1.5M16 16l-1-1M17 12l1 1',
|
||||
}
|
||||
|
||||
function StatIcon({ icon, size = 32, className }: { icon: string; size?: number; className?: string }) {
|
||||
const path = iconPaths[icon]
|
||||
if (!path) return null
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CountUpValue({
|
||||
target,
|
||||
duration,
|
||||
prefix,
|
||||
suffix,
|
||||
fallback,
|
||||
trigger,
|
||||
}: {
|
||||
target: number
|
||||
duration: number
|
||||
prefix: string
|
||||
suffix: string
|
||||
fallback: string
|
||||
trigger: string
|
||||
}) {
|
||||
const [count, setCount] = useState<number | null>(null)
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const hasAnimated = useRef(false)
|
||||
|
||||
const animate = useCallback(() => {
|
||||
if (hasAnimated.current) return
|
||||
hasAnimated.current = true
|
||||
const start = performance.now()
|
||||
const step = (now: number) => {
|
||||
const elapsed = now - start
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||
setCount(Math.round(eased * target))
|
||||
if (progress < 1) requestAnimationFrame(step)
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
}, [target, duration])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
observer.disconnect()
|
||||
if (trigger === 'immediate') {
|
||||
animate()
|
||||
return
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) animate() },
|
||||
{ threshold: 0.3 },
|
||||
)
|
||||
if (sectionRef.current) observer.observe(sectionRef.current)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
}, [animate, trigger])
|
||||
|
||||
const bgClasses: Record<string, string> = {
|
||||
return (
|
||||
<span ref={ref}>
|
||||
{count !== null
|
||||
? `${prefix}${count.toLocaleString('de-DE')}${suffix}`
|
||||
: `${prefix}${fallback}${suffix}`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
none: 'bg-soft-white',
|
||||
light: 'bg-ivory',
|
||||
dark: 'bg-espresso text-soft-white',
|
||||
|
|
@ -38,28 +110,258 @@ export function StatsBlock({
|
|||
gradient: 'bg-gradient-to-br from-espresso to-brass text-soft-white',
|
||||
}
|
||||
|
||||
const valueSizeMap: Record<string, string> = {
|
||||
base: 'text-2xl',
|
||||
lg: 'text-3xl md:text-4xl',
|
||||
xl: 'text-4xl md:text-5xl',
|
||||
'2xl': 'text-5xl md:text-6xl',
|
||||
}
|
||||
|
||||
const valueWeightMap: Record<string, string> = {
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
bold: 'font-bold',
|
||||
extrabold: 'font-extrabold',
|
||||
}
|
||||
|
||||
const gapMap: Record<string, string> = {
|
||||
'16': 'gap-4',
|
||||
'24': 'gap-6',
|
||||
'32': 'gap-8',
|
||||
'48': 'gap-12',
|
||||
}
|
||||
|
||||
const paddingMap: Record<string, string> = {
|
||||
none: 'py-0',
|
||||
sm: 'py-8',
|
||||
md: 'py-16 md:py-24',
|
||||
lg: 'py-24 md:py-32',
|
||||
}
|
||||
|
||||
export function StatsBlock({
|
||||
title,
|
||||
subtitle,
|
||||
stats,
|
||||
layout = 'row',
|
||||
columns = '4',
|
||||
alignment = 'center',
|
||||
animation,
|
||||
style,
|
||||
}: StatsBlockData) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
|
||||
const countUp = animation?.countUp !== false
|
||||
const duration = parseInt((animation?.duration as string) || '2000', 10)
|
||||
const trigger = (animation?.trigger as string) || 'viewport'
|
||||
const stagger = animation?.stagger !== false
|
||||
|
||||
const bg = (style?.bg as string) || 'none'
|
||||
const textColor = (style?.textColor as string) || 'auto'
|
||||
const valueSize = (style?.valueSize as string) || 'xl'
|
||||
const valueWeight = (style?.valueWeight as string) || 'bold'
|
||||
const showIcon = style?.showIcon !== false
|
||||
const iconPosition = (style?.iconPosition as string) || 'top'
|
||||
const iconAlignment = ((style as Record<string, unknown>)?.iconAlignment as string) || 'center'
|
||||
const dividers = Boolean(style?.dividers)
|
||||
const cardBorder = Boolean(style?.cardBorder)
|
||||
const cardShadow = style?.cardShadow !== false
|
||||
const gap = (style?.gap as string) || '32'
|
||||
const padding = (style?.padding as string) || 'md'
|
||||
|
||||
const isDark = bg === 'dark' || bg === 'primary' || bg === 'gradient'
|
||||
const isLight = textColor === 'light' || (textColor === 'auto' && isDark)
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const colsClass: Record<string, string> = {
|
||||
'2': 'md:grid-cols-2',
|
||||
'3': 'md:grid-cols-2 lg:grid-cols-3',
|
||||
'4': 'md:grid-cols-2 lg:grid-cols-4',
|
||||
auto: (stats?.length ?? 0) <= 2
|
||||
? 'md:grid-cols-2'
|
||||
: (stats?.length ?? 0) === 3
|
||||
? 'md:grid-cols-3'
|
||||
: 'md:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
|
||||
const alignClass = alignment === 'left'
|
||||
? 'text-left items-start'
|
||||
: alignment === 'right'
|
||||
? 'text-right items-end'
|
||||
: 'text-center items-center'
|
||||
|
||||
const isRow = layout === 'row' || layout === 'inline'
|
||||
const isCards = layout === 'cards'
|
||||
const isFeature = layout === 'feature'
|
||||
|
||||
const gridClass = isRow
|
||||
? cn('flex flex-wrap justify-center', gapMap[gap])
|
||||
: isFeature
|
||||
? cn('grid grid-cols-1 md:grid-cols-2', gapMap[gap])
|
||||
: cn('grid grid-cols-1', colsClass[columns || "4"] || colsClass['4'], gapMap[gap])
|
||||
|
||||
const accentColor = isLight ? 'text-brass' : 'text-brass'
|
||||
const labelColor = isLight ? 'text-soft-white/70' : 'text-warm-gray-dark'
|
||||
const descColor = isLight ? 'text-soft-white/60' : 'text-warm-gray-dark'
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className={cn('py-16 md:py-24', bgClasses[style?.bg || 'none'])}>
|
||||
<div className="container">
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={cn(paddingMap[padding], bgMap[bg])}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6">
|
||||
{(title || subtitle) && (
|
||||
<div className="text-center mb-12 md:mb-16">
|
||||
{title && <h2 className={cn('mb-3 transition-all duration-700 ease-out', isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4')}>{title}</h2>}
|
||||
{subtitle && <p className={cn('text-lg opacity-70 max-w-2xl mx-auto transition-all duration-700 ease-out delay-100', isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4')}>{subtitle}</p>}
|
||||
<div className={cn('mb-12 md:mb-16', alignClass)}>
|
||||
{title && (
|
||||
<h2 className={cn(
|
||||
'font-headline mb-3 transition-all duration-700 ease-out',
|
||||
isLight && 'text-soft-white',
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4',
|
||||
)}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className={cn(
|
||||
'text-lg max-w-2xl transition-all duration-700 ease-out delay-100',
|
||||
labelColor,
|
||||
alignment === 'center' && 'mx-auto',
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4',
|
||||
)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('grid gap-8 md:gap-12', layout === 'row' ? 'grid-cols-1 md:grid-cols-3' : 'grid-cols-2 md:grid-cols-4')}>
|
||||
{stats?.map((stat, index) => (
|
||||
<div key={stat.id || index} className={cn('text-center group transition-all duration-700 ease-out', isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8')} style={{ transitionDelay: isVisible ? `${200 + index * 100}ms` : '0ms' }}>
|
||||
<div className="relative mb-4">
|
||||
<span className={cn('block font-headline text-5xl md:text-6xl lg:text-7xl font-semibold', style?.bg === 'dark' || style?.bg === 'primary' || style?.bg === 'gradient' ? 'text-soft-white' : 'text-brass', 'transition-transform duration-500 ease-out group-hover:scale-105')}>
|
||||
{stat.prefix}{stat.value ?? stat.label}{stat.suffix}
|
||||
|
||||
<div className={cn(gridClass, alignClass)}>
|
||||
{stats?.map((stat, index) => {
|
||||
const value = (stat.value as string) || ''
|
||||
const numericValue = stat.numericValue as number | undefined
|
||||
const prefix = (stat.prefix as string) || ''
|
||||
const suffix = (stat.suffix as string) || ''
|
||||
const label = (stat.label as string) || ''
|
||||
const description = (stat.description as string) || ''
|
||||
const icon = (stat.icon as string) || 'none'
|
||||
const hasIcon = showIcon && icon !== 'none' && icon in iconPaths
|
||||
|
||||
const staggerDelay = stagger ? `${200 + index * 100}ms` : '0ms'
|
||||
|
||||
const isInline = iconPosition === 'left' || iconPosition === 'inline'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stat.id || index}
|
||||
className={cn(
|
||||
'flex flex-col transition-all duration-700 ease-out',
|
||||
isRow && 'flex-1 min-w-[150px] max-w-[280px]',
|
||||
isFeature && 'p-8 md:p-12',
|
||||
isCards && cn(
|
||||
'p-8 rounded-lg',
|
||||
isLight ? 'bg-soft-white/10 backdrop-blur-sm' : 'bg-white',
|
||||
cardBorder && (isLight ? 'border border-soft-white/20' : 'border border-warm-gray'),
|
||||
cardShadow && 'shadow-md hover:shadow-lg',
|
||||
'hover:-translate-y-1 transition-all duration-300',
|
||||
),
|
||||
dividers && !isCards && index > 0 && (
|
||||
isRow
|
||||
? cn('border-l pl-8', isLight ? 'border-soft-white/20' : 'border-warm-gray')
|
||||
: ''
|
||||
),
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||
)}
|
||||
style={{ transitionDelay: isVisible ? staggerDelay : '0ms' }}
|
||||
>
|
||||
{/* Icon above value */}
|
||||
{hasIcon && iconPosition === 'top' && (
|
||||
<div className={cn(
|
||||
'mb-4',
|
||||
accentColor,
|
||||
iconAlignment === 'center' && 'flex justify-center',
|
||||
iconAlignment === 'right' && 'flex justify-end',
|
||||
)}>
|
||||
<StatIcon icon={icon} size={isFeature ? 48 : 36} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Value row (with optional inline icon) */}
|
||||
<div className={cn(
|
||||
isInline && hasIcon && 'flex items-center gap-3',
|
||||
iconAlignment === 'center' && isInline && 'justify-center',
|
||||
'mb-3',
|
||||
)}>
|
||||
{hasIcon && isInline && (
|
||||
<span className={accentColor}>
|
||||
<StatIcon icon={icon} size={iconPosition === 'inline' ? 28 : 36} />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
'font-headline tracking-tight block',
|
||||
valueSizeMap[valueSize],
|
||||
valueWeightMap[valueWeight],
|
||||
isLight ? 'text-soft-white' : 'text-espresso',
|
||||
'transition-transform duration-500 ease-out group-hover:scale-105',
|
||||
)}>
|
||||
{countUp && numericValue != null ? (
|
||||
<CountUpValue
|
||||
target={numericValue}
|
||||
duration={duration}
|
||||
prefix={prefix}
|
||||
suffix={suffix}
|
||||
fallback={value}
|
||||
trigger={trigger}
|
||||
/>
|
||||
) : (
|
||||
<>{prefix}{value}{suffix}</>
|
||||
)}
|
||||
</span>
|
||||
<div className={cn('absolute -bottom-2 left-1/2 -translate-x-1/2 h-0.5 bg-gradient-to-r from-transparent via-brass/40 to-transparent transition-all duration-500 ease-out', isVisible ? 'w-16 opacity-100' : 'w-0 opacity-0')} style={{ transitionDelay: isVisible ? `${400 + index * 100}ms` : '0ms' }} />
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-medium mb-2">{stat.label}</h3>
|
||||
{stat.description && <p className="text-sm opacity-60 max-w-xs mx-auto leading-relaxed">{stat.description}</p>}
|
||||
|
||||
{/* Decorative underline */}
|
||||
<div className={cn(
|
||||
'h-0.5 bg-gradient-to-r from-transparent via-brass/40 to-transparent mb-4 transition-all duration-500 ease-out',
|
||||
alignment === 'left' && 'from-brass/40 via-brass/20 to-transparent',
|
||||
isVisible ? 'w-16 opacity-100' : 'w-0 opacity-0',
|
||||
alignment === 'center' && 'mx-auto',
|
||||
alignment === 'right' && 'ml-auto',
|
||||
)}
|
||||
style={{ transitionDelay: isVisible ? `${400 + index * 100}ms` : '0ms' }}
|
||||
/>
|
||||
|
||||
<h3 className={cn(
|
||||
'text-lg md:text-xl font-medium mb-2',
|
||||
isLight ? 'text-soft-white' : 'text-espresso',
|
||||
)}>
|
||||
{label}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p className={cn(
|
||||
'text-sm leading-relaxed max-w-xs',
|
||||
alignment === 'center' && 'mx-auto',
|
||||
descColor,
|
||||
)}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Reference in a new issue