From 46034873b6e2d51103755ebd36174a8f1a031b8a Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Fri, 27 Feb 2026 16:20:47 +0000 Subject: [PATCH] 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 --- src/components/blocks/StatsBlock.tsx | 360 ++++++++++++++++++++++++--- 1 file changed, 331 insertions(+), 29 deletions(-) diff --git a/src/components/blocks/StatsBlock.tsx b/src/components/blocks/StatsBlock.tsx index 5eaf2f5..c3478a0 100644 --- a/src/components/blocks/StatsBlock.tsx +++ b/src/components/blocks/StatsBlock.tsx @@ -1,22 +1,180 @@ '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, 'blockType' | 'blockName'> +type StatsBlockData = Omit, 'blockType' | 'blockName'> + +// Inline SVG icons (no external icon library) +const iconPaths: Record = { + 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 ( + + + + ) +} + +function CountUpValue({ + target, + duration, + prefix, + suffix, + fallback, + trigger, +}: { + target: number + duration: number + prefix: string + suffix: string + fallback: string + trigger: string +}) { + const [count, setCount] = useState(null) + const ref = useRef(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(() => { + if (trigger === 'immediate') { + animate() + return + } + const el = ref.current + if (!el) return + const observer = new IntersectionObserver( + ([entry]) => { if (entry.isIntersecting) animate() }, + { threshold: 0.3 }, + ) + observer.observe(el) + return () => observer.disconnect() + }, [animate, trigger]) + + return ( + + {count !== null + ? `${prefix}${count.toLocaleString('de-DE')}${suffix}` + : `${prefix}${fallback}${suffix}`} + + ) +} + +const bgMap: Record = { + none: 'bg-soft-white', + light: 'bg-ivory', + dark: 'bg-espresso text-soft-white', + primary: 'bg-brass text-soft-white', + gradient: 'bg-gradient-to-br from-espresso to-brass text-soft-white', +} + +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-8', + md: 'py-16 md:py-24', + lg: 'py-24 md:py-32', +} export function StatsBlock({ title, subtitle, stats, - style, layout = 'row', -}: StatsBlockProps) { + columns = '4', + alignment = 'center', + animation, + style, +}: StatsBlockData) { const [isVisible, setIsVisible] = useState(false) const sectionRef = useRef(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)?.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) { @@ -24,42 +182,186 @@ export function StatsBlock({ observer.disconnect() } }, - { threshold: 0.2 } + { threshold: 0.15 }, ) - if (sectionRef.current) observer.observe(sectionRef.current) + observer.observe(el) return () => observer.disconnect() }, []) - const bgClasses: Record = { - none: 'bg-soft-white', - light: 'bg-ivory', - dark: 'bg-espresso text-soft-white', - primary: 'bg-brass text-soft-white', - gradient: 'bg-gradient-to-br from-espresso to-brass text-soft-white', + const colsClass: 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 ?? 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 ( -
-
+
+
{(title || subtitle) && ( -
- {title &&

{title}

} - {subtitle &&

{subtitle}

} +
+ {title && ( +

+ {title} +

+ )} + {subtitle && ( +

+ {subtitle} +

+ )}
)} -
- {stats?.map((stat, index) => ( -
-
- - {stat.prefix}{stat.value ?? stat.label}{stat.suffix} - -
+ +
+ {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 ( +
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' && ( +
+ +
+ )} + + {/* Value row (with optional inline icon) */} +
+ {hasIcon && isInline && ( + + + + )} + + {countUp && numericValue != null ? ( + + ) : ( + <>{prefix}{value}{suffix} + )} + +
+ + {/* Decorative underline */} +
+ +

+ {label} +

+ + {description && ( +

+ {description} +

+ )}
-

{stat.label}

- {stat.description &&

{stat.description}

} -
- ))} + ) + })}