mirror of
https://github.com/complexcaresolutions/frontend.porwoll.de.git
synced 2026-03-17 15:13:42 +00:00
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:
parent
6e7292aaf5
commit
b9816b9bfd
2 changed files with 250 additions and 0 deletions
248
src/components/blocks/StatsBlock.tsx
Normal file
248
src/components/blocks/StatsBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue