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>
This commit is contained in:
CCS Admin 2026-02-05 15:16:17 +00:00
parent dcfc48f5ce
commit 75f31b1cb8
19 changed files with 2609 additions and 23 deletions

View file

@ -1,34 +1,76 @@
import type { Metadata } from "next"; import type { Metadata } from 'next'
import { Geist, Geist_Mono } from "next/font/google"; import { Playfair_Display, Inter } from 'next/font/google'
import "./globals.css"; import { Header, Footer } from '@/components/layout'
import { UmamiScript } from '@/components/analytics'
import { getSiteSettings, getNavigation } from '@/lib/api'
import './globals.css'
const geistSans = Geist({ const playfair = Playfair_Display({
variable: "--font-geist-sans", variable: '--font-playfair',
subsets: ["latin"], subsets: ['latin'],
}); display: 'swap',
})
const geistMono = Geist_Mono({ const inter = Inter({
variable: "--font-geist-mono", variable: '--font-inter',
subsets: ["latin"], subsets: ['latin'],
}); display: 'swap',
})
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Create Next App", const settings = await getSiteSettings()
description: "Generated by create next app",
};
export default function RootLayout({ return {
title: {
default: settings?.siteName || 'BlogWoman',
template: `%s | ${settings?.siteName || 'BlogWoman'}`,
},
description: settings?.siteDescription || 'Lifestyle-Blog für moderne Frauen',
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://blogwoman.de'),
openGraph: {
type: 'website',
locale: 'de_DE',
siteName: settings?.siteName || 'BlogWoman',
},
twitter: {
card: 'summary_large_image',
},
robots: {
index: true,
follow: true,
},
}
}
export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
// Fetch navigation and settings in parallel
const [headerNav, footerNav, settings] = await Promise.all([
getNavigation('header'),
getNavigation('footer'),
getSiteSettings(),
])
return ( return (
<html lang="en"> <html lang="de" className={`${playfair.variable} ${inter.variable}`}>
<body <body className="font-body text-espresso bg-ivory antialiased">
className={`${geistSans.variable} ${geistMono.variable} antialiased`} {/* Skip to main content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-brass focus:text-soft-white focus:rounded-lg focus:outline-none"
> >
{children} Zum Hauptinhalt springen
</a>
<div className="flex min-h-screen flex-col">
<Header navigation={headerNav} settings={settings} />
<main id="main-content" className="flex-1">{children}</main>
<Footer navigation={footerNav} settings={settings} />
</div>
<UmamiScript />
</body> </body>
</html> </html>
); )
} }

69
src/app/not-found.tsx Normal file
View file

@ -0,0 +1,69 @@
import Link from 'next/link'
import { Button } from '@/components/ui'
export default function NotFound() {
return (
<section className="min-h-[70vh] flex items-center justify-center py-16">
<div className="container">
<div className="max-w-2xl mx-auto text-center">
{/* 404 Visual */}
<div className="mb-8">
<p className="text-8xl md:text-9xl font-headline font-bold text-brass/20 select-none">
404
</p>
</div>
{/* Content */}
<h1 className="text-2xl md:text-3xl font-semibold mb-4">
Seite nicht gefunden
</h1>
<p className="text-lg text-espresso/80 mb-8">
Die gesuchte Seite existiert leider nicht oder wurde verschoben.
Keine Sorge, hier sind einige hilfreiche Links:
</p>
{/* Primary Action */}
<div className="mb-12">
<Button href="/" variant="primary" size="lg">
Zur Startseite
</Button>
</div>
{/* Helpful Links */}
<div className="border-t border-warm-gray pt-8">
<p className="text-sm text-warm-gray-dark mb-4">Beliebte Seiten</p>
<nav className="flex flex-wrap justify-center gap-4">
<Link
href="/blog"
className="text-espresso hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2 rounded"
>
Blog
</Link>
<span className="text-warm-gray" aria-hidden="true">·</span>
<Link
href="/serien"
className="text-espresso hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2 rounded"
>
Serien
</Link>
<span className="text-warm-gray" aria-hidden="true">·</span>
<Link
href="/favoriten"
className="text-espresso hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2 rounded"
>
Favoriten
</Link>
<span className="text-warm-gray" aria-hidden="true">·</span>
<Link
href="/ueber-mich"
className="text-espresso hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2 rounded"
>
Über mich
</Link>
</nav>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,40 @@
import type { DividerBlock as DividerBlockType } from '@/lib/types'
type DividerBlockProps = Omit<DividerBlockType, 'blockType'>
export function DividerBlock({ style = 'line', text }: DividerBlockProps) {
if (style === 'space') {
return <div className="py-12" />
}
if (style === 'dots') {
return (
<div className="py-12 flex justify-center gap-2">
<span className="w-2 h-2 rounded-full bg-warm-gray" />
<span className="w-2 h-2 rounded-full bg-warm-gray" />
<span className="w-2 h-2 rounded-full bg-warm-gray" />
</div>
)
}
// Line with optional text
if (text) {
return (
<div className="py-12 container">
<div className="flex items-center gap-4">
<div className="flex-1 h-px bg-warm-gray" />
<span className="text-sm font-medium text-espresso/60 uppercase tracking-widest">
{text}
</span>
<div className="flex-1 h-px bg-warm-gray" />
</div>
</div>
)
}
return (
<div className="py-12 container">
<div className="h-px bg-warm-gray" />
</div>
)
}

View file

@ -0,0 +1,198 @@
'use client'
import { useState } from 'react'
import Script from 'next/script'
import { cn } from '@/lib/utils'
import { RichTextRenderer } from './RichTextRenderer'
import type { FAQBlock as FAQBlockType, FAQ } from '@/lib/types'
type FAQBlockProps = Omit<FAQBlockType, 'blockType'> & {
faqs?: FAQ[]
}
export function FAQBlock({
title,
subtitle,
displayMode,
selectedFaqs,
filterCategory,
layout = 'accordion',
expandFirst = false,
showSchema = true,
faqs: externalFaqs,
}: FAQBlockProps) {
// Use selectedFaqs if displayMode is 'selected', otherwise use externalFaqs
const items = displayMode === 'selected' ? selectedFaqs : externalFaqs
if (!items || items.length === 0) return null
// Generate JSON-LD schema data
const schemaData = showSchema
? {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: extractTextFromRichText(faq.answer),
},
})),
}
: null
return (
<section className="py-16 md:py-20">
<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>
)}
{/* FAQ Items */}
<div className="max-w-3xl mx-auto">
{layout === 'accordion' ? (
<AccordionFAQ items={items} expandFirst={expandFirst} />
) : layout === 'grid' ? (
<GridFAQ items={items} />
) : (
<ListFAQ items={items} />
)}
</div>
{/* JSON-LD Schema using Next.js Script component for safety */}
{schemaData && (
<Script
id="faq-schema"
type="application/ld+json"
strategy="afterInteractive"
>
{JSON.stringify(schemaData)}
</Script>
)}
</div>
</section>
)
}
function AccordionFAQ({
items,
expandFirst,
}: {
items: FAQ[]
expandFirst: boolean
}) {
const [openIndex, setOpenIndex] = useState<number | null>(
expandFirst ? 0 : null
)
return (
<div className="space-y-4">
{items.map((faq, index) => (
<div
key={faq.id}
className="border border-warm-gray rounded-xl overflow-hidden"
>
<button
type="button"
className="w-full px-6 py-4 flex items-center justify-between text-left bg-soft-white hover:bg-ivory transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-inset"
onClick={() => setOpenIndex(openIndex === index ? null : index)}
aria-expanded={openIndex === index}
>
<span className="font-headline text-lg font-medium text-espresso pr-4">
{faq.question}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className={cn(
'w-5 h-5 text-brass transition-transform duration-200 flex-shrink-0',
openIndex === index && 'rotate-180'
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
<div
className={cn(
'grid transition-all duration-300 ease-out',
openIndex === index
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
)}
>
<div className="overflow-hidden">
<div className="px-6 pb-6 pt-2">
<RichTextRenderer content={faq.answer} />
</div>
</div>
</div>
</div>
))}
</div>
)
}
function ListFAQ({ items }: { items: FAQ[] }) {
return (
<div className="space-y-8">
{items.map((faq) => (
<div key={faq.id}>
<h3 className="text-xl font-semibold mb-3">{faq.question}</h3>
<RichTextRenderer content={faq.answer} />
</div>
))}
</div>
)
}
function GridFAQ({ items }: { items: FAQ[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{items.map((faq) => (
<div
key={faq.id}
className="bg-soft-white border border-warm-gray rounded-xl p-6"
>
<h3 className="text-lg font-semibold mb-3">{faq.question}</h3>
<RichTextRenderer content={faq.answer} />
</div>
))}
</div>
)
}
// Helper to extract plain text from RichText for schema
function extractTextFromRichText(richText: FAQ['answer']): string {
if (!richText?.root?.children) return ''
function extractFromNode(node: Record<string, unknown>): string {
if (node.text) return node.text as string
const children = node.children as Record<string, unknown>[] | undefined
if (children) {
return children.map(extractFromNode).join('')
}
return ''
}
return richText.root.children
.map((node) => extractFromNode(node as Record<string, unknown>))
.join(' ')
.trim()
}

View file

@ -0,0 +1,198 @@
import Image from 'next/image'
import { cn, getImageUrl } from '@/lib/utils'
import { Badge } from '@/components/ui'
import { getFavorites } from '@/lib/api'
import type { FavoritesBlock as FavoritesBlockType, Favorite } from '@/lib/types'
type FavoritesBlockProps = Omit<FavoritesBlockType, 'blockType'>
export async function FavoritesBlock({
title,
subtitle,
displayMode,
selectedFavorites,
filterCategory,
layout = 'grid',
columns = 3,
limit = 12,
showPrice = true,
showBadge = true,
}: FavoritesBlockProps) {
// Fetch favorites if not using selected mode
let items: Favorite[] = []
if (displayMode === 'selected' && selectedFavorites) {
items = selectedFavorites
} else {
const favoritesData = await getFavorites({
category: filterCategory,
limit,
})
items = favoritesData.docs
}
if (!items || items.length === 0) return null
const columnClasses = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-2 lg:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-4',
}
const badgeLabels: Record<string, string> = {
'investment-piece': 'Investment-Piece',
'daily-driver': 'Täglicher Begleiter',
'grfi-approved': 'GRFI-Favorit',
new: 'Neu',
bestseller: 'Bestseller',
}
const badgeVariants: Record<string, 'new' | 'popular' | 'investment' | 'daily' | 'grfi' | 'default'> = {
'investment-piece': 'investment',
'daily-driver': 'daily',
'grfi-approved': 'grfi',
new: 'new',
bestseller: 'popular',
}
return (
<section className="py-16 md:py-20">
<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>
)}
{/* Favorites Grid */}
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>
{items.map((favorite) => (
<FavoriteCard
key={favorite.id}
favorite={favorite}
showPrice={showPrice}
showBadge={showBadge}
badgeLabels={badgeLabels}
badgeVariants={badgeVariants}
/>
))}
</div>
{/* Affiliate Disclosure */}
<div className="mt-12 flex items-start gap-3 p-4 bg-brass/10 rounded-lg max-w-2xl mx-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5 text-brass flex-shrink-0 mt-0.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
<p className="text-sm text-espresso">
Einige Links auf dieser Seite sind Affiliate-Links. Das bedeutet, dass ich eine kleine Provision erhalte, wenn du über diese Links einkaufst - ohne Mehrkosten für dich.
</p>
</div>
</div>
</section>
)
}
interface FavoriteCardProps {
favorite: Favorite
showPrice?: boolean
showBadge?: boolean
badgeLabels: Record<string, string>
badgeVariants: Record<string, 'new' | 'popular' | 'investment' | 'daily' | 'grfi' | 'default'>
}
function FavoriteCard({
favorite,
showPrice,
showBadge,
badgeLabels,
badgeVariants,
}: FavoriteCardProps) {
const imageUrl = getImageUrl(favorite.image)
return (
<a
href={favorite.affiliateUrl}
target="_blank"
rel="noopener noreferrer sponsored"
className="group block"
>
<article className="bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl">
{/* Image */}
<div className="relative aspect-square bg-ivory">
{imageUrl && (
<Image
src={imageUrl}
alt={favorite.image?.alt || favorite.title}
fill
className="object-cover"
/>
)}
{/* Badge */}
{showBadge && favorite.badge && (
<div className="absolute top-3 left-3">
<Badge variant={badgeVariants[favorite.badge] || 'default'}>
{badgeLabels[favorite.badge]}
</Badge>
</div>
)}
</div>
{/* Content */}
<div className="p-5">
{favorite.category && (
<p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2">
{favorite.category}
</p>
)}
<h3 className="font-headline text-lg font-semibold text-espresso mb-2 group-hover:text-brass transition-colors">
{favorite.title}
</h3>
{/* Footer */}
<div className="flex items-center justify-between mt-4">
{showPrice && favorite.price && (
<span className="font-semibold text-espresso">
{favorite.price}
</span>
)}
<span className="inline-flex items-center gap-1 text-sm font-medium text-brass group-hover:gap-2 transition-all">
Ansehen
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
</span>
</div>
</div>
</article>
</a>
)
}

View file

@ -0,0 +1,83 @@
import Image from 'next/image'
import { Button } from '@/components/ui'
import { cn } from '@/lib/utils'
import type { HeroBlock as HeroBlockType } from '@/lib/types'
type HeroBlockProps = Omit<HeroBlockType, 'blockType'>
export function HeroBlock({
heading,
subheading,
backgroundImage,
ctaText,
ctaLink,
alignment = 'center',
overlay = true,
overlayOpacity = 50,
}: HeroBlockProps) {
const alignmentClasses = {
left: 'text-left items-start',
center: 'text-center items-center',
right: 'text-right items-end',
}
return (
<section className="relative min-h-[60vh] md:min-h-[70vh] flex items-center">
{/* Background Image */}
{backgroundImage?.url && (
<div className="absolute inset-0">
<Image
src={backgroundImage.url}
alt={backgroundImage.alt || ''}
fill
className="object-cover"
priority
/>
{overlay && (
<div
className="absolute inset-0 bg-espresso"
style={{ opacity: overlayOpacity / 100 }}
/>
)}
</div>
)}
{/* Content */}
<div className="container relative z-10">
<div
className={cn(
'flex flex-col max-w-3xl py-20',
alignmentClasses[alignment],
alignment === 'center' && 'mx-auto'
)}
>
<h1
className={cn(
'mb-6',
backgroundImage ? 'text-soft-white' : 'text-espresso'
)}
>
{heading}
</h1>
{subheading && (
<p
className={cn(
'text-lg md:text-xl leading-relaxed mb-8',
backgroundImage ? 'text-soft-white/90' : 'text-espresso'
)}
>
{subheading}
</p>
)}
{ctaText && ctaLink && (
<Button href={ctaLink} size="lg">
{ctaText}
</Button>
)}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,176 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { Button, Input } from '@/components/ui'
import { cn } from '@/lib/utils'
import { subscribeNewsletter } from '@/lib/api'
import type { NewsletterBlock as NewsletterBlockType } from '@/lib/types'
type NewsletterBlockProps = Omit<NewsletterBlockType, 'blockType'>
export function NewsletterBlock({
title = 'Newsletter',
subtitle,
buttonText = 'Anmelden',
layout = 'card',
backgroundImage,
showPrivacyNote = true,
source = 'newsletter-block',
showFirstName = false,
}: NewsletterBlockProps) {
const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('loading')
setErrorMessage('')
try {
const result = await subscribeNewsletter(
email,
showFirstName && firstName ? firstName : undefined,
source
)
if (result.success) {
setStatus('success')
setEmail('')
} else {
setStatus('error')
setErrorMessage(result.message || 'Ein Fehler ist aufgetreten.')
}
} catch {
setStatus('error')
setErrorMessage('Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.')
}
}
const formContent = (
<>
{status === 'success' ? (
<div className="p-4 bg-success/10 text-success rounded-lg text-center">
<p className="font-medium">Vielen Dank für Ihre Anmeldung!</p>
<p className="text-sm mt-1">Bitte bestätigen Sie Ihre E-Mail-Adresse.</p>
</div>
) : (
<form onSubmit={handleSubmit} className={cn(
'flex flex-col gap-4',
(layout === 'inline' || layout === 'minimal') && !showFirstName && 'sm:flex-row'
)}>
{showFirstName && (
<Input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Dein Vorname (optional)"
disabled={status === 'loading'}
/>
)}
<div className={cn(
'flex flex-col gap-4',
(layout === 'inline' || layout === 'minimal') && 'sm:flex-row'
)}>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Deine E-Mail-Adresse"
required
disabled={status === 'loading'}
error={status === 'error' ? errorMessage : undefined}
className={cn(
(layout === 'inline' || layout === 'minimal') && 'sm:flex-1'
)}
/>
<Button
type="submit"
disabled={status === 'loading'}
className="whitespace-nowrap"
>
{status === 'loading' ? 'Wird gesendet...' : buttonText}
</Button>
</div>
</form>
)}
{showPrivacyNote && status !== 'success' && (
<p className="text-sm text-warm-gray-dark mt-4 text-center">
Mit der Anmeldung akzeptieren Sie unsere{' '}
<Link href="/datenschutz" className="underline hover:text-espresso">
Datenschutzerklärung
</Link>
.
</p>
)}
</>
)
// Minimal layout
if (layout === 'minimal') {
return (
<section className="py-8">
<div className="container max-w-xl">
{formContent}
</div>
</section>
)
}
// Card layout (default)
return (
<section className="py-16 md:py-20">
<div className="container">
<div
className={cn(
'relative bg-soft-white border border-warm-gray rounded-2xl p-8 md:p-10 overflow-hidden',
backgroundImage && 'text-soft-white'
)}
>
{/* Background Image */}
{backgroundImage?.url && (
<div className="absolute inset-0">
<Image
src={backgroundImage.url}
alt=""
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-espresso/60" />
</div>
)}
<div className="relative z-10 max-w-xl mx-auto text-center">
{title && (
<h2
className={cn(
'mb-3',
backgroundImage && 'text-soft-white'
)}
>
{title}
</h2>
)}
{subtitle && (
<p
className={cn(
'text-lg mb-6',
backgroundImage ? 'text-soft-white/80' : 'text-espresso/80'
)}
>
{subtitle}
</p>
)}
{formContent}
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,373 @@
import Image from 'next/image'
import Link from 'next/link'
import { cn, formatDate, getImageUrl } from '@/lib/utils'
import { getPosts } from '@/lib/api'
import { EmptyState } from '@/components/ui'
import type { PostsListBlock as PostsListBlockType, Post, Author } from '@/lib/types'
// Helper to get author name safely
function getAuthorName(author: Author | string | undefined): string | null {
if (!author) return null
if (typeof author === 'string') return null
return author.name
}
type PostsListBlockProps = Omit<PostsListBlockType, 'blockType'>
export async function PostsListBlock({
title,
subtitle,
postType,
layout = 'grid',
columns = 3,
limit = 6,
showFeaturedOnly,
filterByCategory,
showExcerpt = true,
showDate = true,
showAuthor = false,
showCategory = true,
showPagination = false,
backgroundColor = 'ivory',
}: PostsListBlockProps) {
const postsData = await getPosts({
type: postType,
category: filterByCategory,
limit,
featured: showFeaturedOnly,
})
const posts = postsData.docs
if (!posts || posts.length === 0) {
return (
<section className="py-16 md:py-20 bg-ivory">
<div className="container">
{(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>
)}
<EmptyState
icon="posts"
title="Noch keine Beiträge"
description="Hier erscheinen bald neue Inhalte. Schau später noch einmal vorbei!"
action={{ label: 'Zur Startseite', href: '/' }}
/>
</div>
</section>
)
}
const bgClasses = {
white: 'bg-soft-white',
ivory: 'bg-ivory',
sand: 'bg-sand/20',
}
const columnClasses = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-2 lg:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-4',
}
return (
<section className={cn('py-16 md:py-20', bgClasses[backgroundColor])}>
<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>
)}
{/* Posts */}
{layout === 'grid' ? (
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
showExcerpt={showExcerpt}
showDate={showDate}
showAuthor={showAuthor}
showCategory={showCategory}
/>
))}
</div>
) : layout === 'featured' ? (
<FeaturedLayout
posts={posts}
showExcerpt={showExcerpt}
showDate={showDate}
showCategory={showCategory}
/>
) : layout === 'list' ? (
<ListLayout
posts={posts}
showExcerpt={showExcerpt}
showDate={showDate}
showCategory={showCategory}
/>
) : (
<CompactLayout posts={posts} showDate={showDate} />
)}
{/* Pagination would go here */}
{showPagination && postsData.totalPages > 1 && (
<div className="mt-12 text-center">
{/* Implement pagination component */}
</div>
)}
</div>
</section>
)
}
interface PostCardProps {
post: Post
showExcerpt?: boolean
showDate?: boolean
showAuthor?: boolean
showCategory?: boolean
}
function PostCard({
post,
showExcerpt,
showDate,
showAuthor,
showCategory,
}: PostCardProps) {
const imageUrl = getImageUrl(post.featuredImage)
const postUrl = getPostUrl(post)
return (
<Link href={postUrl} className="group block">
<article className="bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl">
{imageUrl && (
<div className="relative aspect-video overflow-hidden">
<Image
src={imageUrl}
alt={post.featuredImage?.alt || post.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
)}
<div className="p-6">
{/* Meta */}
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-3">
{showCategory && post.categories?.[0] && (
<span className="text-brass font-medium">
{post.categories[0].title}
</span>
)}
{showDate && post.publishedAt && (
<span>{formatDate(post.publishedAt)}</span>
)}
</div>
{/* Title */}
<h3 className="text-xl font-semibold mb-2 group-hover:text-brass transition-colors">
{post.title}
</h3>
{/* Excerpt */}
{showExcerpt && post.excerpt && (
<p className="text-espresso/80 line-clamp-2">{post.excerpt}</p>
)}
{/* Author */}
{showAuthor && getAuthorName(post.author) && (
<p className="mt-4 text-sm text-warm-gray-dark">
von {getAuthorName(post.author)}
</p>
)}
</div>
</article>
</Link>
)
}
function FeaturedLayout({
posts,
showExcerpt,
showDate,
showCategory,
}: {
posts: Post[]
showExcerpt?: boolean
showDate?: boolean
showCategory?: boolean
}) {
const [featured, ...rest] = posts
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Featured Post */}
<Link href={getPostUrl(featured)} className="group block lg:row-span-2">
<article className="h-full bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-xl">
{featured.featuredImage && (
<div className="relative aspect-[4/3] lg:aspect-[16/10] overflow-hidden">
<Image
src={featured.featuredImage.url}
alt={featured.featuredImage.alt || featured.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
)}
<div className="p-6 lg:p-8">
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-3">
{showCategory && featured.categories?.[0] && (
<span className="text-brass font-medium">
{featured.categories[0].title}
</span>
)}
{showDate && featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div>
<h3 className="text-2xl lg:text-3xl font-semibold mb-3 group-hover:text-brass transition-colors">
{featured.title}
</h3>
{showExcerpt && featured.excerpt && (
<p className="text-espresso/80 line-clamp-3">{featured.excerpt}</p>
)}
</div>
</article>
</Link>
{/* Secondary Posts */}
<div className="space-y-6">
{rest.slice(0, 2).map((post) => (
<Link key={post.id} href={getPostUrl(post)} className="group block">
<article className="flex gap-4 bg-soft-white border border-warm-gray rounded-xl p-4 transition-all duration-300 hover:shadow-lg">
{post.featuredImage && (
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden">
<Image
src={post.featuredImage.url}
alt={post.featuredImage.alt || post.title}
fill
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
{showDate && post.publishedAt && (
<span className="text-sm text-warm-gray-dark">
{formatDate(post.publishedAt)}
</span>
)}
<h4 className="font-semibold line-clamp-2 group-hover:text-brass transition-colors">
{post.title}
</h4>
</div>
</article>
</Link>
))}
</div>
</div>
)
}
function ListLayout({
posts,
showExcerpt,
showDate,
showCategory,
}: {
posts: Post[]
showExcerpt?: boolean
showDate?: boolean
showCategory?: boolean
}) {
return (
<div className="max-w-3xl mx-auto space-y-6">
{posts.map((post) => (
<Link key={post.id} href={getPostUrl(post)} className="group block">
<article className="flex gap-6 bg-soft-white border border-warm-gray rounded-xl p-6 transition-all duration-300 hover:shadow-lg">
{post.featuredImage && (
<div className="relative w-32 h-32 md:w-48 md:h-32 flex-shrink-0 rounded-lg overflow-hidden">
<Image
src={post.featuredImage.url}
alt={post.featuredImage.alt || post.title}
fill
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-2">
{showCategory && post.categories?.[0] && (
<span className="text-brass font-medium">
{post.categories[0].title}
</span>
)}
{showDate && post.publishedAt && (
<span>{formatDate(post.publishedAt)}</span>
)}
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-brass transition-colors">
{post.title}
</h3>
{showExcerpt && post.excerpt && (
<p className="text-espresso/80 line-clamp-2">{post.excerpt}</p>
)}
</div>
</article>
</Link>
))}
</div>
)
}
function CompactLayout({
posts,
showDate,
}: {
posts: Post[]
showDate?: boolean
}) {
return (
<div className="max-w-2xl mx-auto divide-y divide-warm-gray">
{posts.map((post) => (
<Link
key={post.id}
href={getPostUrl(post)}
className="group block py-4 first:pt-0 last:pb-0"
>
<article className="flex items-center justify-between gap-4">
<h4 className="font-medium group-hover:text-brass transition-colors">
{post.title}
</h4>
{showDate && post.publishedAt && (
<span className="text-sm text-warm-gray-dark whitespace-nowrap">
{formatDate(post.publishedAt)}
</span>
)}
</article>
</Link>
))}
</div>
)
}
function getPostUrl(post: Post): string {
const prefixes: Record<string, string> = {
blog: '/blog',
news: '/news',
press: '/presse',
announcement: '/aktuelles',
}
const prefix = prefixes[post.type] || '/blog'
return `${prefix}/${post.slug}`
}

View file

@ -0,0 +1,160 @@
import Link from 'next/link'
import Image from 'next/image'
import type { RichText } from '@/lib/types'
interface RichTextRendererProps {
content?: RichText | null
className?: string
}
export function RichTextRenderer({ content, className }: RichTextRendererProps) {
if (!content?.root?.children) return null
return (
<div className={className}>
{content.root.children.map((node, index) => (
<RenderNode key={index} node={node as Record<string, unknown>} />
))}
</div>
)
}
interface RenderNodeProps {
node: Record<string, unknown>
}
function RenderNode({ node }: RenderNodeProps) {
const type = node.type as string
const children = node.children as Record<string, unknown>[] | undefined
switch (type) {
case 'paragraph':
return (
<p className="mb-4 last:mb-0">
{children?.map((child, i) => <RenderNode key={i} node={child} />)}
</p>
)
case 'heading':
const tag = node.tag as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
const HeadingTag = tag || 'h2'
const headingClasses: Record<string, string> = {
h1: 'text-4xl md:text-5xl mb-6',
h2: 'text-3xl md:text-4xl mb-5',
h3: 'text-2xl md:text-3xl mb-4',
h4: 'text-xl md:text-2xl mb-3',
h5: 'text-lg md:text-xl mb-3',
h6: 'text-base md:text-lg mb-2',
}
return (
<HeadingTag className={headingClasses[HeadingTag]}>
{children?.map((child, i) => <RenderNode key={i} node={child} />)}
</HeadingTag>
)
case 'list':
const listTag = node.listType === 'number' ? 'ol' : 'ul'
const ListTag = listTag as 'ol' | 'ul'
return (
<ListTag
className={
listTag === 'ol'
? 'list-decimal list-inside mb-4 space-y-2'
: 'list-disc list-inside mb-4 space-y-2'
}
>
{children?.map((child, i) => <RenderNode key={i} node={child} />)}
</ListTag>
)
case 'listitem':
return (
<li>
{children?.map((child, i) => <RenderNode key={i} node={child} />)}
</li>
)
case 'link':
const url = node.url as string
const linkType = node.linkType as string
const newTab = node.newTab as boolean
if (linkType === 'internal') {
return (
<Link
href={url || '/'}
className="text-brass hover:text-brass-hover underline underline-offset-2 transition-colors"
>
{children?.map((child, i) => <RenderNode key={i} node={child} />)}
</Link>
)
}
return (
<a
href={url}
target={newTab ? '_blank' : undefined}
rel={newTab ? 'noopener noreferrer' : undefined}
className="text-brass hover:text-brass-hover underline underline-offset-2 transition-colors"
>
{children?.map((child, i) => <RenderNode key={i} node={child} />)}
</a>
)
case 'upload':
const value = node.value as { url?: string; alt?: string; width?: number; height?: number } | undefined
if (!value?.url) return null
return (
<figure className="my-6">
<Image
src={value.url}
alt={value.alt || ''}
width={value.width || 800}
height={value.height || 600}
className="rounded-lg"
/>
</figure>
)
case 'quote':
return (
<blockquote className="border-l-4 border-brass pl-6 my-6 italic text-lg">
{children?.map((child, i) => <RenderNode key={i} node={child} />)}
</blockquote>
)
case 'text':
const text = node.text as string
if (!text) return null
const format = node.format as number | undefined
let element: React.ReactNode = text
// Apply formatting based on bitmask
if (format) {
if (format & 1) element = <strong>{element}</strong> // bold
if (format & 2) element = <em>{element}</em> // italic
if (format & 8) element = <u>{element}</u> // underline
if (format & 4) element = <s>{element}</s> // strikethrough
if (format & 16) element = <code className="bg-warm-gray px-1 py-0.5 rounded text-sm">{element}</code>
}
return <>{element}</>
case 'linebreak':
return <br />
default:
// For unknown types, try to render children
if (children) {
return (
<>
{children.map((child, i) => (
<RenderNode key={i} node={child} />
))}
</>
)
}
return null
}
}

View file

@ -0,0 +1,229 @@
'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>
)
}

View file

@ -0,0 +1,97 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { cn, extractYouTubeId, getPrivacyYouTubeUrl, getYouTubeThumbnail } from '@/lib/utils'
import type { VideoBlock as VideoBlockType } from '@/lib/types'
type VideoBlockProps = Omit<VideoBlockType, 'blockType'>
export function VideoBlock({
title,
videoUrl,
thumbnailImage,
aspectRatio = '16:9',
}: VideoBlockProps) {
const [isPlaying, setIsPlaying] = useState(false)
const videoId = extractYouTubeId(videoUrl)
const thumbnailUrl = thumbnailImage?.url || (videoId ? getYouTubeThumbnail(videoId) : null)
const embedUrl = videoId ? getPrivacyYouTubeUrl(videoId) : null
const aspectClasses = {
'16:9': 'aspect-video',
'4:3': 'aspect-[4/3]',
'1:1': 'aspect-square',
}
if (!embedUrl) {
return null
}
return (
<section className="py-12 md:py-16">
<div className="container">
{title && (
<h2 className="text-center mb-8">{title}</h2>
)}
<div className="max-w-4xl mx-auto">
<div
className={cn(
'relative rounded-2xl overflow-hidden bg-espresso',
aspectClasses[aspectRatio]
)}
>
{isPlaying ? (
<iframe
src={`${embedUrl}?autoplay=1&rel=0`}
title={title || 'Video'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
) : (
<button
type="button"
onClick={() => setIsPlaying(true)}
className="absolute inset-0 w-full h-full group"
aria-label="Video abspielen"
>
{thumbnailUrl && (
<Image
src={thumbnailUrl}
alt={title || 'Video Thumbnail'}
fill
className="object-cover"
/>
)}
{/* Overlay */}
<div className="absolute inset-0 bg-espresso/50 group-hover:bg-espresso/40 transition-colors" />
{/* Play Button */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-soft-white/90 flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-8 h-8 md:w-10 md:h-10 text-brass ml-1"
>
<path
fillRule="evenodd"
d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
</button>
)}
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,206 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import type { NavigationItem } from '@/lib/types'
interface MobileMenuProps {
items: NavigationItem[]
isOpen: boolean
onClose: () => void
}
export function MobileMenu({ items, isOpen, onClose }: MobileMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const previousActiveElement = useRef<HTMLElement | null>(null)
// Prevent body scroll when menu is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
// Focus management: focus close button when menu opens, return focus when closed
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement
// Small delay to allow animation to start
setTimeout(() => {
closeButtonRef.current?.focus()
}, 100)
} else if (previousActiveElement.current) {
previousActiveElement.current.focus()
previousActiveElement.current = null
}
}, [isOpen])
// Focus trap: keep focus within the menu
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
if (e.key !== 'Tab' || !menuRef.current) return
const focusableElements = menuRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handleKeyDown])
return (
<>
{/* Overlay */}
<div
className={cn(
'fixed inset-0 bg-espresso/30 backdrop-blur-sm z-40',
'transition-opacity duration-300',
isOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Menu Panel */}
<div
ref={menuRef}
role="dialog"
aria-modal="true"
aria-label="Navigation"
className={cn(
'fixed top-0 right-0 h-full w-[300px] max-w-[85vw] bg-soft-white z-50',
'shadow-2xl',
'transition-transform duration-300 ease-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
>
{/* Close Button */}
<div className="flex justify-end p-4 border-b border-warm-gray">
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="p-2 text-espresso hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2 rounded"
aria-label="Menu schließen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Navigation Links */}
<nav className="p-6">
<ul className="space-y-1">
{items.map((item) => (
<MobileNavItem key={item.id} item={item} onClose={onClose} />
))}
</ul>
</nav>
</div>
</>
)
}
interface MobileNavItemProps {
item: NavigationItem
onClose: () => void
depth?: number
}
function MobileNavItem({ item, onClose, depth = 0 }: MobileNavItemProps) {
const linkClasses = cn(
'block py-3 text-base font-medium text-espresso',
'hover:text-brass transition-colors',
depth > 0 && 'pl-4 text-sm'
)
// With children
if (item.type === 'submenu' && item.children?.length) {
return (
<li>
<span className="block py-3 text-base font-semibold text-espresso">
{item.label}
</span>
<ul className="border-l-2 border-warm-gray ml-2">
{item.children.map((child) => (
<MobileNavItem
key={child.id}
item={child}
onClose={onClose}
depth={depth + 1}
/>
))}
</ul>
</li>
)
}
// External link
if (item.type === 'external' && item.url) {
return (
<li>
<a
href={item.url}
target={item.openInNewTab ? '_blank' : undefined}
rel={item.openInNewTab ? 'noopener noreferrer' : undefined}
className={linkClasses}
onClick={onClose}
>
{item.label}
</a>
</li>
)
}
// Internal link
const href = item.page?.slug ? `/${item.page.slug}` : item.url || '/'
return (
<li>
<Link href={href} className={linkClasses} onClick={onClose}>
{item.label}
</Link>
</li>
)
}

View file

@ -0,0 +1,101 @@
import Link from 'next/link'
import { cn } from '@/lib/utils'
import type { ComponentPropsWithoutRef } from 'react'
type ButtonVariant = 'primary' | 'secondary' | 'tertiary'
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonBaseProps {
variant?: ButtonVariant
size?: ButtonSize
className?: string
children: React.ReactNode
}
interface ButtonAsButton extends ButtonBaseProps, Omit<ComponentPropsWithoutRef<'button'>, keyof ButtonBaseProps> {
href?: never
external?: never
}
interface ButtonAsLink extends ButtonBaseProps {
href: string
external?: boolean
}
type ButtonProps = ButtonAsButton | ButtonAsLink
const variantStyles: Record<ButtonVariant, string> = {
primary: `
bg-brass text-soft-white
hover:bg-brass-hover hover:-translate-y-0.5 hover:shadow-md
active:translate-y-0
`,
secondary: `
bg-transparent text-espresso border-[1.5px] border-espresso
hover:bg-espresso/10 hover:border-espresso
`,
tertiary: `
bg-transparent text-espresso
hover:text-brass
`,
}
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-5 py-2.5 min-h-[40px] text-[13px]',
md: 'px-7 py-3.5 min-h-[48px] text-sm',
lg: 'px-9 py-4.5 min-h-[56px] text-base',
}
export function Button({
variant = 'primary',
size = 'md',
className,
children,
...props
}: ButtonProps) {
const baseStyles = `
inline-flex items-center justify-center gap-2
font-semibold tracking-[0.02em]
border-none rounded-lg
cursor-pointer
transition-all duration-200 ease-out
focus-visible:outline-2 focus-visible:outline-brass focus-visible:outline-offset-2
`
const combinedClassName = cn(
baseStyles,
variantStyles[variant],
sizeStyles[size],
className
)
if ('href' in props && props.href) {
const { href, external, ...rest } = props as ButtonAsLink
if (external) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={combinedClassName}
{...rest}
>
{children}
</a>
)
}
return (
<Link href={href} className={combinedClassName} {...rest}>
{children}
</Link>
)
}
return (
<button className={combinedClassName} {...(props as ButtonAsButton)}>
{children}
</button>
)
}

115
src/components/ui/Card.tsx Normal file
View file

@ -0,0 +1,115 @@
import Link from 'next/link'
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface CardProps {
className?: string
children: React.ReactNode
href?: string
hover?: boolean
}
export function Card({
className,
children,
href,
hover = true,
}: CardProps) {
const baseStyles = cn(
'group bg-soft-white border border-warm-gray rounded-2xl overflow-hidden',
hover && 'transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-xl',
href && 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2',
className
)
if (href) {
return (
<Link href={href} className={baseStyles}>
{children}
</Link>
)
}
return <div className={baseStyles}>{children}</div>
}
interface CardImageProps {
src: string
alt: string
aspectRatio?: '16:9' | '4:3' | '1:1' | '3:4'
className?: string
}
export function CardImage({
src,
alt,
aspectRatio = '16:9',
className,
}: CardImageProps) {
const aspectRatioClasses = {
'16:9': 'aspect-video',
'4:3': 'aspect-[4/3]',
'1:1': 'aspect-square',
'3:4': 'aspect-[3/4]',
}
return (
<div className={cn('relative overflow-hidden', aspectRatioClasses[aspectRatio], className)}>
<Image
src={src}
alt={alt}
fill
className="object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
</div>
)
}
interface CardContentProps {
className?: string
children: React.ReactNode
}
export function CardContent({ className, children }: CardContentProps) {
return (
<div className={cn('p-6', className)}>
{children}
</div>
)
}
interface CardTitleProps {
className?: string
children: React.ReactNode
as?: 'h2' | 'h3' | 'h4' | 'h5'
}
export function CardTitle({
className,
children,
as: Component = 'h3',
}: CardTitleProps) {
return (
<Component
className={cn(
'font-headline text-lg font-semibold text-espresso leading-tight mb-2',
className
)}
>
{children}
</Component>
)
}
interface CardDescriptionProps {
className?: string
children: React.ReactNode
}
export function CardDescription({ className, children }: CardDescriptionProps) {
return (
<p className={cn('text-base text-espresso leading-relaxed', className)}>
{children}
</p>
)
}

View file

@ -0,0 +1,111 @@
import { cn } from '@/lib/utils'
import { Button } from './Button'
interface EmptyStateProps {
icon?: 'posts' | 'favorites' | 'search' | 'error'
title: string
description?: string
action?: {
label: string
href: string
}
className?: string
}
const icons = {
posts: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-12 h-12"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
),
favorites: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-12 h-12"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
/>
</svg>
),
search: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-12 h-12"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
),
error: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-12 h-12"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
),
}
export function EmptyState({
icon = 'posts',
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-16 px-4 text-center',
className
)}
>
<div className="text-warm-gray mb-6">{icons[icon]}</div>
<h3 className="text-xl font-semibold text-espresso mb-2">{title}</h3>
{description && (
<p className="text-espresso/70 max-w-md mb-6">{description}</p>
)}
{action && (
<Button href={action.href} variant="secondary" size="sm">
{action.label}
</Button>
)}
</div>
)
}

View file

@ -0,0 +1,199 @@
import Link from 'next/link'
import { cn } from '@/lib/utils'
interface PaginationProps {
currentPage: number
totalPages: number
basePath: string
className?: string
}
export function Pagination({
currentPage,
totalPages,
basePath,
className,
}: PaginationProps) {
if (totalPages <= 1) return null
const pages = generatePageNumbers(currentPage, totalPages)
const getPageUrl = (page: number) => {
if (page === 1) return basePath
return `${basePath}?page=${page}`
}
return (
<nav
aria-label="Seitennavigation"
className={cn('flex items-center justify-center gap-2', className)}
>
{/* Previous Button */}
<PaginationLink
href={currentPage > 1 ? getPageUrl(currentPage - 1) : undefined}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-4 h-4"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</PaginationLink>
{/* Page Numbers */}
{pages.map((page, index) => {
if (page === 'ellipsis') {
return (
<span
key={`ellipsis-${index}`}
className="px-2 text-warm-gray-dark"
>
...
</span>
)
}
return (
<PaginationLink
key={page}
href={getPageUrl(page)}
active={page === currentPage}
aria-label={`Seite ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</PaginationLink>
)
})}
{/* Next Button */}
<PaginationLink
href={currentPage < totalPages ? getPageUrl(currentPage + 1) : undefined}
disabled={currentPage === totalPages}
aria-label="Nächste Seite"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-4 h-4"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</PaginationLink>
</nav>
)
}
interface PaginationLinkProps {
href?: string
active?: boolean
disabled?: boolean
children: React.ReactNode
'aria-label'?: string
'aria-current'?: 'page'
}
function PaginationLink({
href,
active,
disabled,
children,
...props
}: PaginationLinkProps) {
const baseClasses = cn(
'inline-flex items-center justify-center',
'min-w-[40px] h-10 px-3',
'text-sm font-medium rounded-lg',
'transition-colors duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2'
)
if (disabled || !href) {
return (
<span
className={cn(
baseClasses,
'text-warm-gray cursor-not-allowed'
)}
{...props}
>
{children}
</span>
)
}
if (active) {
return (
<span
className={cn(
baseClasses,
'bg-brass text-soft-white'
)}
{...props}
>
{children}
</span>
)
}
return (
<Link
href={href}
className={cn(
baseClasses,
'text-espresso hover:bg-brass/10 hover:text-brass',
'border border-warm-gray'
)}
{...props}
>
{children}
</Link>
)
}
function generatePageNumbers(
currentPage: number,
totalPages: number
): (number | 'ellipsis')[] {
const pages: (number | 'ellipsis')[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
return pages
}
// Always show first page
pages.push(1)
if (currentPage > 3) {
pages.push('ellipsis')
}
// Show pages around current
const start = Math.max(2, currentPage - 1)
const end = Math.min(totalPages - 1, currentPage + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (currentPage < totalPages - 2) {
pages.push('ellipsis')
}
// Always show last page
pages.push(totalPages)
return pages
}

View file

@ -0,0 +1,107 @@
import { cn } from '@/lib/utils'
type SeriesType =
| 'grfi'
| 'investment'
| 'pl'
| 'spark'
| 'inner-circle'
| 'reset'
| 'decision'
| 'regeneration'
| 'm2m'
| 'backstage'
type PillSize = 'sm' | 'md' | 'lg'
interface SeriesPillProps {
series: SeriesType | string
children: React.ReactNode
size?: PillSize
className?: string
}
const seriesColors: Record<string, { bg: string; text: string }> = {
grfi: { bg: 'bg-sand', text: 'text-espresso' },
investment: { bg: 'bg-brass', text: 'text-soft-white' },
pl: { bg: 'bg-bordeaux', text: 'text-soft-white' },
spark: { bg: 'bg-rose', text: 'text-espresso' },
'inner-circle': { bg: 'bg-gold', text: 'text-espresso' },
reset: { bg: 'bg-sand', text: 'text-espresso' },
decision: { bg: 'bg-sand', text: 'text-espresso' },
regeneration: { bg: 'bg-sand', text: 'text-espresso' },
m2m: { bg: 'bg-sand', text: 'text-espresso' },
backstage: { bg: 'bg-warm-gray', text: 'text-espresso' },
}
const sizeClasses: Record<PillSize, string> = {
sm: 'px-2 py-1 text-[10px]',
md: 'px-3.5 py-1.5 text-[11px]',
lg: 'px-6 py-2.5 text-[13px]',
}
export function SeriesPill({
series,
children,
size = 'md',
className,
}: SeriesPillProps) {
const seriesKey = series.toLowerCase().replace(/\s+/g, '-')
const colors = seriesColors[seriesKey] || seriesColors.grfi
return (
<span
className={cn(
'inline-flex items-center',
sizeClasses[size],
colors.bg,
colors.text,
'font-bold tracking-[0.08em] uppercase',
'rounded-full',
className
)}
>
{children}
</span>
)
}
// Generic badge component for other use cases
interface BadgeProps {
variant?: 'default' | 'new' | 'popular' | 'success' | 'warning' | 'error' | 'investment' | 'daily' | 'grfi'
children: React.ReactNode
className?: string
}
const badgeVariants: Record<string, string> = {
default: 'bg-sand text-espresso',
new: 'bg-brass text-soft-white',
popular: 'bg-bordeaux text-soft-white',
success: 'bg-success text-soft-white',
warning: 'bg-warning text-espresso',
error: 'bg-error text-soft-white',
investment: 'bg-gold text-espresso',
daily: 'bg-sand text-espresso',
grfi: 'bg-rose text-espresso',
}
export function Badge({
variant = 'default',
children,
className,
}: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center',
'px-2.5 py-1',
'text-xs font-semibold',
'rounded-md',
badgeVariants[variant],
className
)}
>
{children}
</span>
)
}

View file

@ -0,0 +1,75 @@
import { cn } from '@/lib/utils'
interface SkeletonProps {
className?: string
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse rounded-lg bg-warm-gray/40',
'motion-reduce:animate-none',
className
)}
/>
)
}
export function SkeletonText({ className }: SkeletonProps) {
return (
<div className={cn('space-y-2', className)}>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
)
}
export function SkeletonCard({ className }: SkeletonProps) {
return (
<div
className={cn(
'bg-soft-white border border-warm-gray rounded-2xl overflow-hidden',
className
)}
>
<Skeleton className="aspect-video rounded-none" />
<div className="p-6 space-y-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-6 w-3/4" />
<SkeletonText />
</div>
</div>
)
}
export function SkeletonPostGrid({ count = 6, columns = 3 }: { count?: number; columns?: 2 | 3 | 4 }) {
const columnClasses = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-2 lg:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-4',
}
return (
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>
{Array.from({ length: count }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
)
}
export function SkeletonHero() {
return (
<div className="relative min-h-[60vh] md:min-h-[70vh] flex items-center bg-warm-gray/20">
<div className="container">
<div className="max-w-3xl mx-auto space-y-6 py-20">
<Skeleton className="h-12 md:h-16 w-3/4 mx-auto" />
<Skeleton className="h-6 w-2/3 mx-auto" />
<Skeleton className="h-12 w-40 mx-auto rounded-lg" />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,7 @@
export { Button } from './Button'
export { Card, CardImage, CardContent, CardTitle, CardDescription } from './Card'
export { EmptyState } from './EmptyState'
export { Input, Textarea } from './Input'
export { Pagination } from './Pagination'
export { SeriesPill, Badge } from './SeriesPill'
export { Skeleton, SkeletonText, SkeletonCard, SkeletonPostGrid, SkeletonHero } from './Skeleton'