From b9816b9bfd31fbeed663608235ef1c69c89a2289 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Wed, 25 Feb 2026 17:44:58 +0000 Subject: [PATCH] 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 --- src/components/blocks/StatsBlock.tsx | 248 +++++++++++++++++++++++++++ src/components/blocks/index.tsx | 2 + 2 files changed, 250 insertions(+) create mode 100644 src/components/blocks/StatsBlock.tsx diff --git a/src/components/blocks/StatsBlock.tsx b/src/components/blocks/StatsBlock.tsx new file mode 100644 index 0000000..452eb4c --- /dev/null +++ b/src/components/blocks/StatsBlock.tsx @@ -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 +} + +// Map CMS icon values to Lucide icon names +const iconMap: Record = { + 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 = { + none: '', + light: 'bg-light-bg', + dark: 'bg-dark', + primary: 'bg-accent', + gradient: 'bg-gradient-to-br from-dark to-gray', +} + +const valueSizeMap: Record = { + base: 'text-2xl', + lg: 'text-3xl md:text-4xl', + xl: 'text-4xl md:text-5xl', + '2xl': 'text-5xl md:text-6xl', +} + +const valueWeightMap: Record = { + normal: 'font-normal', + medium: 'font-medium', + bold: 'font-bold', + extrabold: 'font-extrabold', +} + +const gapMap: Record = { + '16': 'gap-4', + '24': 'gap-6', + '32': 'gap-8', + '48': 'gap-12', +} + +const paddingMap: Record = { + 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(null) + const ref = useRef(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 ( + + {count !== null + ? `${prefix}${count.toLocaleString('de-DE')}${suffix}` + : `${prefix}${fallback}${suffix}` + } + + ) +} + +export function StatsBlock({ block }: StatsBlockProps) { + const title = (block.title as string) || '' + const subtitle = (block.subtitle as string) || '' + const stats = (block.stats as Array>) || [] + 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) || {} + const countUp = animation.countUp !== false + const duration = parseInt((animation.duration as string) || '2000', 10) + + const style = (block.style as Record) || {} + 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 = { + '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 ( +
+ + {(title || subtitle) && ( + + )} + +
+ {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 ( +
0 && 'border-l border-light pl-8', + )} + > + {showIcon && lucideIcon && ( +
+ +
+ )} + +
+ {countUp && numericValue != null ? ( + + ) : ( + <>{prefix}{value}{suffix} + )} +
+ +

+ {label} +

+ + {description && ( +

+ {description} +

+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/components/blocks/index.tsx b/src/components/blocks/index.tsx index 076a27f..d583058 100644 --- a/src/components/blocks/index.tsx +++ b/src/components/blocks/index.tsx @@ -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 }>> = { @@ -28,6 +29,7 @@ const blockComponents: Record