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:
CCS Admin 2026-02-27 16:20:47 +00:00
parent 3812592cdb
commit 46034873b6

View file

@ -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>