mirror of
https://github.com/complexcaresolutions/frontend.porwoll.de.git
synced 2026-03-17 16:23:41 +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 { QuoteBlock } from './QuoteBlock'
|
||||||
import { ContactFormBlock } from './ContactFormBlock'
|
import { ContactFormBlock } from './ContactFormBlock'
|
||||||
import { TimelineBlock } from './TimelineBlock'
|
import { TimelineBlock } from './TimelineBlock'
|
||||||
|
import { StatsBlock } from './StatsBlock'
|
||||||
|
|
||||||
// Block component registry
|
// Block component registry
|
||||||
const blockComponents: Record<string, React.ComponentType<{ block: Record<string, unknown> }>> = {
|
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,
|
'quote-block': QuoteBlock,
|
||||||
'contact-form-block': ContactFormBlock,
|
'contact-form-block': ContactFormBlock,
|
||||||
'timeline-block': TimelineBlock,
|
'timeline-block': TimelineBlock,
|
||||||
|
'stats-block': StatsBlock,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder for blocks not yet implemented
|
// Placeholder for blocks not yet implemented
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue