mirror of
https://github.com/complexcaresolutions/frontend.blogwoman.de.git
synced 2026-03-17 16:14:00 +00:00
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>
229 lines
7.4 KiB
TypeScript
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'
|
|
)}
|
|
>
|
|
“{testimonial.quote}”
|
|
</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>
|
|
)
|
|
}
|