feat: add dynamic Lucide icon rendering to CardGridBlock

Installs lucide-react and creates DynamicIcon component that maps
icon name strings to rendered Lucide icons. CardGridBlock now supports
mediaType (none/image/icon) and iconPosition (top/left).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-16 15:57:41 +00:00
parent fe83855f40
commit 9fe9876de2
4 changed files with 95 additions and 39 deletions

View file

@ -12,6 +12,7 @@
"@c2s/payload-contracts": "github:complexcaresolutions/payload-contracts",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
"lucide-react": "^0.564.0",
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1",

View file

@ -17,6 +17,9 @@ importers:
framer-motion:
specifier: ^12.34.0
version: 12.34.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
lucide-react:
specifier: ^0.564.0
version: 0.564.0(react@19.2.1)
next:
specifier: 16.0.10
version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@ -1492,6 +1495,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.564.0:
resolution: {integrity: sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -3498,6 +3506,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.564.0(react@19.2.1):
dependencies:
react: 19.2.1
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5

View file

@ -5,6 +5,7 @@ import Link from 'next/link'
import Image from 'next/image'
import { Container } from '../ui/Container'
import { SectionHeader } from '../ui/SectionHeader'
import { DynamicIcon } from '../ui/DynamicIcon'
import { cn } from '@/lib/utils'
interface CardGridBlockProps {
@ -40,13 +41,17 @@ export function CardGridBlock({ block }: CardGridBlockProps) {
{cards.map((card, index) => {
const cardTitle = (card.title as string) || ''
const cardDescription = (card.description as string) || (card.text as string) || ''
const mediaType = (card.mediaType as string) || 'none'
const cardIcon = card.icon as string | undefined
const iconPosition = (card.iconPosition as string) || 'top'
const cardImage = card.image as Record<string, unknown> | undefined
const cardImageUrl = cardImage?.url as string | undefined
const cardLink = card.link as Record<string, unknown> | undefined
const cardLinkLabel = (cardLink?.label as string) || 'Mehr erfahren'
const cardLinkHref = (cardLink?.href as string) || (cardLink?.url as string) || ''
const isIconLeft = mediaType === 'icon' && iconPosition === 'left'
return (
<motion.div
key={index}
@ -54,57 +59,67 @@ export function CardGridBlock({ block }: CardGridBlockProps) {
'p-[50px_40px]',
'border border-light-soft bg-white',
'shadow-card transition-all duration-300',
'hover:-translate-y-[10px] hover:shadow-card-hover'
'hover:-translate-y-[10px] hover:shadow-card-hover',
isIconLeft && 'flex gap-6'
)}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1, duration: 0.5 }}
>
{/* Icon */}
{cardIcon && (
<div className="text-[64px] text-dark mb-6">
<span>{cardIcon}</span>
{/* Icon left */}
{mediaType === 'icon' && cardIcon && isIconLeft && (
<div className="shrink-0 text-dark">
<DynamicIcon name={cardIcon} size={48} strokeWidth={1.5} />
</div>
)}
{/* Image */}
{cardImageUrl && (
<div className="mb-6 -mx-[40px] -mt-[50px]">
<Image
src={cardImageUrl}
alt={cardTitle}
width={400}
height={250}
className="w-full h-auto"
/>
</div>
)}
<div className={isIconLeft ? 'flex-1' : ''}>
{/* Icon top */}
{mediaType === 'icon' && cardIcon && !isIconLeft && (
<div className="text-dark mb-6">
<DynamicIcon name={cardIcon} size={64} strokeWidth={1.5} />
</div>
)}
{/* Title */}
<h3 className="font-heading font-bold text-[1.07em] tracking-[3px] uppercase text-dark mt-0 mb-[30px]">
{cardTitle}
</h3>
{/* Image */}
{mediaType === 'image' && cardImageUrl && (
<div className="mb-6 -mx-[40px] -mt-[50px]">
<Image
src={cardImageUrl}
alt={cardTitle}
width={400}
height={250}
className="w-full h-auto"
/>
</div>
)}
{/* Description */}
<p className="text-gray leading-[1.8em] m-0">
{cardDescription}
</p>
{/* Title */}
<h3 className="font-heading font-bold text-[1.07em] tracking-[3px] uppercase text-dark mt-0 mb-[30px]">
{cardTitle}
</h3>
{/* Link */}
{cardLinkHref && (
<Link
href={cardLinkHref}
className={cn(
'block pt-[50px] text-right',
'font-heading text-[0.85em] tracking-[2px] uppercase',
'text-gray-light hover:text-dark',
'border-none transition-colors duration-500'
)}
>
{cardLinkLabel} &rarr;
</Link>
)}
{/* Description */}
<p className="text-gray leading-[1.8em] m-0">
{cardDescription}
</p>
{/* Link */}
{cardLinkHref && (
<Link
href={cardLinkHref}
className={cn(
'block pt-[50px] text-right',
'font-heading text-[0.85em] tracking-[2px] uppercase',
'text-gray-light hover:text-dark',
'border-none transition-colors duration-500'
)}
>
{cardLinkLabel} &rarr;
</Link>
)}
</div>
</motion.div>
)
})}

View file

@ -0,0 +1,28 @@
import { icons, type LucideIcon } from 'lucide-react'
interface DynamicIconProps {
name: string
size?: number
className?: string
strokeWidth?: number
}
/**
* Renders a Lucide icon by name string.
* Names can be kebab-case ("shield-check") or PascalCase ("ShieldCheck").
*/
export function DynamicIcon({ name, size = 24, className, strokeWidth = 2 }: DynamicIconProps) {
// Convert kebab-case to PascalCase: "shield-check" → "ShieldCheck"
const pascalName = name
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
const IconComponent = icons[pascalName as keyof typeof icons] as LucideIcon | undefined
if (!IconComponent) {
return null
}
return <IconComponent size={size} className={className} strokeWidth={strokeWidth} />
}