feat: add StatsBlock component for CMS stats-block rendering

- Maps CMS icon values to Lucide icons
- Supports row/grid/cards layouts, all CMS style options
- Count-up animation with IntersectionObserver
- SSR-safe: renders fallback values, no opacity:0 initial state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-25 17:44:58 +00:00
parent 6e7292aaf5
commit b9816b9bfd
2 changed files with 250 additions and 0 deletions

View file

@ -0,0 +1,248 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Container } from '../ui/Container'
import { SectionHeader } from '../ui/SectionHeader'
import { DynamicIcon } from '../ui/DynamicIcon'
import { cn } from '@/lib/utils'
interface StatsBlockProps {
block: Record<string, unknown>
}
// Map CMS icon values to Lucide icon names
const iconMap: Record<string, string> = {
users: 'Users',
star: 'Star',
heart: 'Heart',
check: 'CheckCircle',
trophy: 'Trophy',
chart: 'BarChart3',
clock: 'Clock',
calendar: 'Calendar',
globe: 'Globe',
building: 'Building2',
document: 'FileText',
target: 'Target',
rocket: 'Rocket',
handshake: 'Handshake',
}
const bgMap: Record<string, string> = {
none: '',
light: 'bg-light-bg',
dark: 'bg-dark',
primary: 'bg-accent',
gradient: 'bg-gradient-to-br from-dark to-gray',
}
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-ws-s',
md: 'py-ws-m',
lg: 'py-ws-l',
}
function CountUpValue({ target, duration, prefix, suffix, fallback }: {
target: number
duration: number
prefix: string
suffix: string
fallback: string
}) {
const [count, setCount] = useState<number | null>(null)
const ref = useRef<HTMLSpanElement>(null)
const hasAnimated = useRef(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !hasAnimated.current) {
hasAnimated.current = true
const start = performance.now()
const animate = (now: number) => {
const elapsed = now - start
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
setCount(Math.round(eased * target))
if (progress < 1) requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
}
},
{ threshold: 0.3 }
)
observer.observe(el)
return () => observer.disconnect()
}, [target, duration])
return (
<span ref={ref}>
{count !== null
? `${prefix}${count.toLocaleString('de-DE')}${suffix}`
: `${prefix}${fallback}${suffix}`
}
</span>
)
}
export function StatsBlock({ block }: StatsBlockProps) {
const title = (block.title as string) || ''
const subtitle = (block.subtitle as string) || ''
const stats = (block.stats as Array<Record<string, unknown>>) || []
const layout = (block.layout as string) || 'row'
const columns = (block.columns as string) || '4'
const alignment = (block.alignment as string) || 'center'
const animation = (block.animation as Record<string, unknown>) || {}
const countUp = animation.countUp !== false
const duration = parseInt((animation.duration as string) || '2000', 10)
const style = (block.style as Record<string, unknown>) || {}
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 dividers = Boolean(style.dividers)
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)
const colsMap: 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 <= 2
? 'md:grid-cols-2'
: stats.length === 3
? 'md:grid-cols-3'
: 'md:grid-cols-2 lg:grid-cols-4',
}
const alignmentClass = alignment === 'left'
? 'text-left'
: alignment === 'right'
? 'text-right'
: 'text-center'
const isRow = layout === 'row' || layout === 'inline'
const isCards = layout === 'cards'
const gridClass = isRow
? cn('flex flex-wrap justify-center', gapMap[gap] || 'gap-8')
: cn('grid grid-cols-1', colsMap[columns] || colsMap['4'], gapMap[gap] || 'gap-8')
return (
<section className={cn(
paddingMap[padding] || 'py-ws-m',
bgMap[bg] || '',
)}>
<Container>
{(title || subtitle) && (
<SectionHeader title={title} subtitle={subtitle} light={isLight} />
)}
<div className={cn(gridClass, alignmentClass)}>
{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 colorClass = isLight ? 'text-white' : 'text-dark'
const lucideIcon = icon !== 'none' ? iconMap[icon] : null
return (
<div
key={(stat.id as string) || index}
className={cn(
isRow && 'flex-1 min-w-[150px] max-w-[280px]',
isCards && cn(
'p-8 bg-white border border-light-soft',
'shadow-card transition-all duration-300',
'hover:-translate-y-1 hover:shadow-card-hover',
),
!isCards && dividers && index > 0 && 'border-l border-light pl-8',
)}
>
{showIcon && lucideIcon && (
<div className={cn('mb-4', colorClass)}>
<DynamicIcon name={lucideIcon} size={40} strokeWidth={1.5} />
</div>
)}
<div className={cn(
'font-heading tracking-[2px]',
valueSizeMap[valueSize] || valueSizeMap.xl,
valueWeightMap[valueWeight] || valueWeightMap.bold,
colorClass,
'mb-3',
)}>
{countUp && numericValue != null ? (
<CountUpValue
target={numericValue}
duration={duration}
prefix={prefix}
suffix={suffix}
fallback={value}
/>
) : (
<>{prefix}{value}{suffix}</>
)}
</div>
<p className={cn(
'font-heading text-[0.85em] tracking-[2px] uppercase',
isLight ? 'text-gray' : 'text-gray-light',
'm-0',
)}>
{label}
</p>
{description && (
<p className={cn(
'text-sm mt-2',
isLight ? 'text-gray' : 'text-gray-light',
)}>
{description}
</p>
)}
</div>
)
})}
</div>
</Container>
</section>
)
}

View file

@ -16,6 +16,7 @@ import { DividerBlock } from './DividerBlock'
import { QuoteBlock } from './QuoteBlock'
import { ContactFormBlock } from './ContactFormBlock'
import { TimelineBlock } from './TimelineBlock'
import { StatsBlock } from './StatsBlock'
// Block component registry
const blockComponents: Record<string, React.ComponentType<{ block: Record<string, unknown> }>> = {
@ -28,6 +29,7 @@ const blockComponents: Record<string, React.ComponentType<{ block: Record<string
'quote-block': QuoteBlock,
'contact-form-block': ContactFormBlock,
'timeline-block': TimelineBlock,
'stats-block': StatsBlock,
}
// Placeholder for blocks not yet implemented