frontend.blogwoman.de/src/components/blocks/TestimonialsBlock.tsx
CCS Admin 75f31b1cb8 Fix design errors, UX issues, and improve accessibility
Critical fixes:
- Add group class to Card component for image zoom on hover
- Create Skeleton, EmptyState, and Pagination UI components
- Add proper empty state to PostsListBlock instead of returning null

Visual consistency:
- Fix Button hover states (subtler secondary/tertiary transitions)
- Add badge variants for FavoritesBlock with German labels
- Increase overlay opacity in HeroBlock/VideoBlock for better contrast

Accessibility improvements:
- Add skip-to-content link in layout for keyboard navigation
- Add focus-visible states to FAQ accordion and Testimonials carousel
- Implement focus trap in MobileMenu with proper ARIA attributes
- Enhance 404 page with helpful navigation links

Polish:
- Fix DividerBlock text contrast
- Fix lint errors (Link component, const declaration)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:16:17 +00:00

229 lines
7.4 KiB
TypeScript

'use client'
import { useState } from 'react'
import Image from 'next/image'
import { cn } from '@/lib/utils'
import type { TestimonialsBlock as TestimonialsBlockType, Testimonial } from '@/lib/types'
type TestimonialsBlockProps = Omit<TestimonialsBlockType, 'blockType'> & {
testimonials?: Testimonial[]
}
export function TestimonialsBlock({
title,
subtitle,
displayMode,
selectedTestimonials,
layout = 'carousel',
testimonials: externalTestimonials,
}: TestimonialsBlockProps) {
const items = displayMode === 'selected' ? selectedTestimonials : externalTestimonials
if (!items || items.length === 0) return null
return (
<section className="py-16 md:py-20 bg-soft-white">
<div className="container">
{/* Section Header */}
{(title || subtitle) && (
<div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>}
{subtitle && (
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div>
)}
{/* Testimonials */}
{layout === 'carousel' ? (
<CarouselLayout items={items} />
) : layout === 'grid' ? (
<GridLayout items={items} />
) : (
<ListLayout items={items} />
)}
</div>
</section>
)
}
function CarouselLayout({ items }: { items: Testimonial[] }) {
const [current, setCurrent] = useState(0)
const prev = () => setCurrent((i) => (i === 0 ? items.length - 1 : i - 1))
const next = () => setCurrent((i) => (i === items.length - 1 ? 0 : i + 1))
return (
<div className="max-w-3xl mx-auto">
<div className="relative">
{items.map((testimonial, index) => (
<div
key={testimonial.id}
className={cn(
'transition-opacity duration-500',
index === current ? 'opacity-100' : 'opacity-0 absolute inset-0'
)}
>
<TestimonialCard testimonial={testimonial} variant="featured" />
</div>
))}
</div>
{/* Navigation */}
{items.length > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
type="button"
onClick={prev}
className="p-2 rounded-full border border-warm-gray hover:border-brass hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2"
aria-label="Vorheriges Testimonial"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-5 h-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div className="flex gap-2">
{items.map((_, index) => (
<button
key={index}
type="button"
onClick={() => setCurrent(index)}
className={cn(
'w-2 h-2 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2',
index === current ? 'bg-brass' : 'bg-warm-gray'
)}
aria-label={`Testimonial ${index + 1}`}
/>
))}
</div>
<button
type="button"
onClick={next}
className="p-2 rounded-full border border-warm-gray hover:border-brass hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2"
aria-label="Nächstes Testimonial"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-5 h-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
)}
</div>
)
}
function GridLayout({ items }: { items: Testimonial[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{items.map((testimonial) => (
<TestimonialCard key={testimonial.id} testimonial={testimonial} />
))}
</div>
)
}
function ListLayout({ items }: { items: Testimonial[] }) {
return (
<div className="max-w-3xl mx-auto space-y-8">
{items.map((testimonial) => (
<TestimonialCard key={testimonial.id} testimonial={testimonial} variant="wide" />
))}
</div>
)
}
interface TestimonialCardProps {
testimonial: Testimonial
variant?: 'default' | 'featured' | 'wide'
}
function TestimonialCard({ testimonial, variant = 'default' }: TestimonialCardProps) {
return (
<div
className={cn(
'bg-ivory border-l-4 border-brass rounded-lg p-8',
variant === 'featured' && 'text-center border-l-0 border-t-4'
)}
>
{/* Quote */}
<blockquote
className={cn(
'font-headline text-xl font-medium italic text-espresso leading-relaxed mb-6',
variant === 'featured' && 'text-2xl'
)}
>
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
{/* Author */}
<div
className={cn(
'flex items-center gap-4',
variant === 'featured' && 'justify-center'
)}
>
{testimonial.authorImage?.url && (
<Image
src={testimonial.authorImage.url}
alt={testimonial.authorName}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover"
/>
)}
<div>
<p className="font-semibold text-espresso">{testimonial.authorName}</p>
{(testimonial.authorTitle || testimonial.authorCompany) && (
<p className="text-sm text-warm-gray-dark">
{[testimonial.authorTitle, testimonial.authorCompany]
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
</div>
{/* Rating */}
{testimonial.rating && (
<div className={cn('flex gap-1 mt-4', variant === 'featured' && 'justify-center')}>
{Array.from({ length: 5 }).map((_, i) => (
<svg
key={i}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={i < testimonial.rating! ? 'currentColor' : 'none'}
stroke="currentColor"
className={cn(
'w-5 h-5',
i < testimonial.rating! ? 'text-gold' : 'text-warm-gray'
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
))}
</div>
)}
</div>
)
}