mirror of
https://github.com/complexcaresolutions/frontend.blogwoman.de.git
synced 2026-03-17 19:44:00 +00:00
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:
parent
dcfc48f5ce
commit
75f31b1cb8
19 changed files with 2609 additions and 23 deletions
|
|
@ -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
69
src/app/not-found.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/blocks/DividerBlock.tsx
Normal file
40
src/components/blocks/DividerBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
198
src/components/blocks/FAQBlock.tsx
Normal file
198
src/components/blocks/FAQBlock.tsx
Normal 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()
|
||||||
|
}
|
||||||
198
src/components/blocks/FavoritesBlock.tsx
Normal file
198
src/components/blocks/FavoritesBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/components/blocks/HeroBlock.tsx
Normal file
83
src/components/blocks/HeroBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
src/components/blocks/NewsletterBlock.tsx
Normal file
176
src/components/blocks/NewsletterBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
373
src/components/blocks/PostsListBlock.tsx
Normal file
373
src/components/blocks/PostsListBlock.tsx
Normal 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}`
|
||||||
|
}
|
||||||
160
src/components/blocks/RichTextRenderer.tsx
Normal file
160
src/components/blocks/RichTextRenderer.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
229
src/components/blocks/TestimonialsBlock.tsx
Normal file
229
src/components/blocks/TestimonialsBlock.tsx
Normal 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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
“{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/components/blocks/VideoBlock.tsx
Normal file
97
src/components/blocks/VideoBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
206
src/components/layout/MobileMenu.tsx
Normal file
206
src/components/layout/MobileMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/components/ui/Button.tsx
Normal file
101
src/components/ui/Button.tsx
Normal 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
115
src/components/ui/Card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
src/components/ui/EmptyState.tsx
Normal file
111
src/components/ui/EmptyState.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
src/components/ui/Pagination.tsx
Normal file
199
src/components/ui/Pagination.tsx
Normal 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
|
||||||
|
}
|
||||||
107
src/components/ui/SeriesPill.tsx
Normal file
107
src/components/ui/SeriesPill.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/components/ui/Skeleton.tsx
Normal file
75
src/components/ui/Skeleton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/components/ui/index.ts
Normal file
7
src/components/ui/index.ts
Normal 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'
|
||||||
Loading…
Reference in a new issue