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", "@c2s/payload-contracts": "github:complexcaresolutions/payload-contracts",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"lucide-react": "^0.564.0",
"next": "16.0.10", "next": "16.0.10",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",

View file

@ -17,6 +17,9 @@ importers:
framer-motion: framer-motion:
specifier: ^12.34.0 specifier: ^12.34.0
version: 12.34.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) 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: next:
specifier: 16.0.10 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) 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: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} 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: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -3498,6 +3506,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@0.564.0(react@19.2.1):
dependencies:
react: 19.2.1
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5

View file

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