feat: migrate all types from local bridge pattern to @c2s/payload-contracts

Complete type migration removing all 33+ local interfaces and as-unknown-as
casts. All components now use contracts types directly with type-safe
relationship resolution via payload-helpers.ts.

Key changes:
- New payload-helpers.ts: resolveRelation, getMediaUrl, getMediaAlt, socialLinksToMap
- types.ts: thin re-export layer from contracts (backward-compatible aliases)
- api.ts: direct contracts types, no bridge casts, typed getSeoSettings
- All 17 block components: correct CMS field names (headline, subline, cta group, etc.)
- All route files: page.seo.metaTitle (not page.meta.title), getMediaUrl for unions
- structuredData.ts: proper types for all schema generators
- Footer: social links from separate collection via socialLinksToMap()
- Header/Footer: resolveMedia for logo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-20 15:25:16 +00:00
parent 7235d8b910
commit 3a8693289f
37 changed files with 896 additions and 2378 deletions

View file

@ -10,7 +10,7 @@ importers:
dependencies: dependencies:
'@c2s/payload-contracts': '@c2s/payload-contracts':
specifier: github:complexcaresolutions/payload-contracts specifier: github:complexcaresolutions/payload-contracts
version: git+https://git@github.com:complexcaresolutions/payload-contracts.git#a0eea9649d35ec2a4554632554d53799a36b7f4b(react@19.2.1) version: git+https://git@github.com:complexcaresolutions/payload-contracts.git#d8e16db5357e5fbac9f701171894a4e641db3df8(react@19.2.1)
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@ -125,8 +125,8 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#a0eea9649d35ec2a4554632554d53799a36b7f4b': '@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#d8e16db5357e5fbac9f701171894a4e641db3df8':
resolution: {commit: a0eea9649d35ec2a4554632554d53799a36b7f4b, repo: git@github.com:complexcaresolutions/payload-contracts.git, type: git} resolution: {commit: d8e16db5357e5fbac9f701171894a4e641db3df8, repo: git@github.com:complexcaresolutions/payload-contracts.git, type: git}
version: 1.0.0 version: 1.0.0
peerDependencies: peerDependencies:
react: ^19.0.0 react: ^19.0.0
@ -2048,7 +2048,7 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#a0eea9649d35ec2a4554632554d53799a36b7f4b(react@19.2.1)': '@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#d8e16db5357e5fbac9f701171894a4e641db3df8(react@19.2.1)':
optionalDependencies: optionalDependencies:
react: 19.2.1 react: 19.2.1

View file

@ -2,6 +2,7 @@ import { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getPage, getPages, getSeoSettings } from '@/lib/api' import { getPage, getPages, getSeoSettings } from '@/lib/api'
import { BlockRenderer } from '@/components/blocks' import { BlockRenderer } from '@/components/blocks'
import { getMediaUrl } from '@/lib/payload-helpers'
import { generateBreadcrumbSchema } from '@/lib/structuredData' import { generateBreadcrumbSchema } from '@/lib/structuredData'
interface PageProps { interface PageProps {
@ -17,7 +18,6 @@ export async function generateStaticParams() {
slug: page.slug, slug: page.slug,
})) }))
} catch { } catch {
// Return empty array if API is unavailable during build
return [] return []
} }
} }
@ -34,12 +34,12 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
} }
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = page.meta?.title || page.title const titleBase = page.seo?.metaTitle || page.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description = const description =
page.meta?.description || seoSettings?.metaDefaults?.defaultDescription page.seo?.metaDescription || seoSettings?.metaDefaults?.defaultDescription
const image = const image =
page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url getMediaUrl(page.seo?.ogImage) || getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
return { return {
title, title,
@ -56,10 +56,6 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
description: description || undefined, description: description || undefined,
images: image ? [image] : undefined, images: image ? [image] : undefined,
}, },
robots: {
index: !page.meta?.noIndex,
follow: !page.meta?.noFollow,
},
} }
} }

View file

@ -2,7 +2,8 @@ import Image from 'next/image'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getPost, getSeoSettings, getSiteSettings } from '@/lib/api' import { getPost, getSeoSettings, getSiteSettings } from '@/lib/api'
import { formatDate, getImageUrl } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import { RichTextRenderer } from '@/components/blocks' import { RichTextRenderer } from '@/components/blocks'
import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData' import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
@ -20,14 +21,14 @@ export async function generateMetadata({ params }: AnnouncementPostPageProps): P
if (!post) return {} if (!post) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = post.meta?.title || post.title const titleBase = post.seo?.metaTitle || post.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description = const description =
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription post.seo?.metaDescription || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
const image = const image =
post.meta?.image?.url || getMediaUrl(post.seo?.ogImage) ||
post.featuredImage?.url || getMediaUrl(post.featuredImage) ||
seoSettings?.metaDefaults?.defaultImage?.url getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
return { return {
title, title,
@ -44,10 +45,6 @@ export async function generateMetadata({ params }: AnnouncementPostPageProps): P
description: description || undefined, description: description || undefined,
images: image ? [image] : undefined, images: image ? [image] : undefined,
}, },
robots: {
index: !post.meta?.noIndex,
follow: !post.meta?.noFollow,
},
} }
} }
@ -62,7 +59,7 @@ export default async function AnnouncementPostPage({ params }: AnnouncementPostP
notFound() notFound()
} }
const imageUrl = getImageUrl(post.featuredImage) const imageUrl = getMediaUrl(post.featuredImage)
const blogSchema = generateBlogPostingSchema(post, settings) const blogSchema = generateBlogPostingSchema(post, settings)
const breadcrumbSchema = generateBreadcrumbSchema([ const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' }, { name: 'Startseite', url: '/' },
@ -94,7 +91,7 @@ export default async function AnnouncementPostPage({ params }: AnnouncementPostP
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10"> <div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image <Image
src={imageUrl} src={imageUrl}
alt={post.featuredImage?.alt || post.title} alt={getMediaAlt(post.featuredImage, post.title)}
fill fill
className="object-cover" className="object-cover"
priority priority

View file

@ -2,9 +2,11 @@ import Image from 'next/image'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getPost, getSeoSettings, getSiteSettings } from '@/lib/api' import { getPost, getSeoSettings, getSiteSettings } from '@/lib/api'
import { formatDate, getImageUrl } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { getMediaUrl, getMediaAlt, resolveRelation } from '@/lib/payload-helpers'
import { RichTextRenderer } from '@/components/blocks' import { RichTextRenderer } from '@/components/blocks'
import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData' import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
import type { Author } from '@c2s/payload-contracts/types'
interface BlogPostPageProps { interface BlogPostPageProps {
params: Promise<{ slug: string }> params: Promise<{ slug: string }>
@ -20,14 +22,14 @@ export async function generateMetadata({ params }: BlogPostPageProps): Promise<M
if (!post) return {} if (!post) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = post.meta?.title || post.title const titleBase = post.seo?.metaTitle || post.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description = const description =
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription post.seo?.metaDescription || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
const image = const image =
post.meta?.image?.url || getMediaUrl(post.seo?.ogImage) ||
post.featuredImage?.url || getMediaUrl(post.featuredImage) ||
seoSettings?.metaDefaults?.defaultImage?.url getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
return { return {
title, title,
@ -44,10 +46,6 @@ export async function generateMetadata({ params }: BlogPostPageProps): Promise<M
description: description || undefined, description: description || undefined,
images: image ? [image] : undefined, images: image ? [image] : undefined,
}, },
robots: {
index: !post.meta?.noIndex,
follow: !post.meta?.noFollow,
},
} }
} }
@ -62,7 +60,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
notFound() notFound()
} }
const imageUrl = getImageUrl(post.featuredImage) const imageUrl = getMediaUrl(post.featuredImage)
const blogSchema = generateBlogPostingSchema(post, settings) const blogSchema = generateBlogPostingSchema(post, settings)
const breadcrumbSchema = generateBreadcrumbSchema([ const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' }, { name: 'Startseite', url: '/' },
@ -94,7 +92,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10"> <div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image <Image
src={imageUrl} src={imageUrl}
alt={post.featuredImage?.alt || post.title} alt={getMediaAlt(post.featuredImage, post.title)}
fill fill
className="object-cover" className="object-cover"
priority priority

View file

@ -2,9 +2,9 @@ import { Metadata } from 'next'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { getPosts } from '@/lib/api' import { getPosts } from '@/lib/api'
import { formatDate, getImageUrl } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { SeriesPill } from '@/components/ui' import { getMediaUrl, getMediaAlt, resolveRelationArray } from '@/lib/payload-helpers'
import type { Post, Series } from '@/lib/types' import type { Post, Category } from '@c2s/payload-contracts/types'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Blog', title: 'Blog',
@ -20,9 +20,7 @@ export default async function BlogPage() {
<section className="py-16 md:py-24"> <section className="py-16 md:py-24">
<div className="container text-center"> <div className="container text-center">
<h1 className="mb-4">Blog</h1> <h1 className="mb-4">Blog</h1>
<p className="text-lg text-espresso/80"> <p className="text-lg text-espresso/80">Noch keine Artikel vorhanden.</p>
Noch keine Artikel vorhanden.
</p>
</div> </div>
</section> </section>
) )
@ -32,24 +30,17 @@ export default async function BlogPage() {
return ( return (
<> <>
{/* Hero / Featured Post */}
<section className="py-12 md:py-16"> <section className="py-12 md:py-16">
<div className="container"> <div className="container">
<h1 className="text-center mb-12">Blog</h1> <h1 className="text-center mb-12">Blog</h1>
{/* Featured Post */}
<FeaturedPostCard post={featured} /> <FeaturedPostCard post={featured} />
</div> </div>
</section> </section>
{/* Posts Grid */}
<section className="py-12 md:py-16 bg-soft-white"> <section className="py-12 md:py-16 bg-soft-white">
<div className="container"> <div className="container">
<h2 className="text-2xl font-semibold mb-8">Alle Artikel</h2> <h2 className="text-2xl font-semibold mb-8">Alle Artikel</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{rest.map((post) => ( {rest.map((post) => <PostCard key={post.id} post={post} />)}
<PostCard key={post.id} post={post} />
))}
</div> </div>
</div> </div>
</section> </section>
@ -58,64 +49,32 @@ export default async function BlogPage() {
} }
function FeaturedPostCard({ post }: { post: Post }) { function FeaturedPostCard({ post }: { post: Post }) {
const imageUrl = getImageUrl(post.featuredImage) const imageUrl = getMediaUrl(post.featuredImage)
const series = post.series as Series | undefined const categories = resolveRelationArray<Category>(post.categories)
return ( return (
<Link href={`/blog/${post.slug}`} className="group block"> <Link href={`/blog/${post.slug}`} className="group block">
<article className="grid md:grid-cols-2 gap-8 items-center"> <article className="grid md:grid-cols-2 gap-8 items-center">
{/* Image */}
<div className="relative aspect-[4/3] rounded-2xl overflow-hidden bg-warm-gray"> <div className="relative aspect-[4/3] rounded-2xl overflow-hidden bg-warm-gray">
{imageUrl && ( {imageUrl && (
<Image <Image src={imageUrl} alt={getMediaAlt(post.featuredImage, post.title)} fill className="object-cover transition-transform duration-500 group-hover:scale-105" priority />
src={imageUrl}
alt={post.featuredImage?.alt || post.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
priority
/>
)} )}
</div> </div>
{/* Content */}
<div> <div>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
{series && ( {categories[0] && (
<SeriesPill series={series.slug}>{series.title}</SeriesPill> <span className="text-sm text-brass font-medium">{categories[0].name}</span>
)} )}
<time <time dateTime={post.publishedAt || post.createdAt} className="text-sm text-espresso/60">
dateTime={post.publishedAt || post.createdAt}
className="text-sm text-espresso/60"
>
{formatDate(post.publishedAt || post.createdAt)} {formatDate(post.publishedAt || post.createdAt)}
</time> </time>
</div> </div>
<h2 className="text-2xl md:text-3xl font-semibold mb-4 group-hover:text-brass transition-colors">{post.title}</h2>
<h2 className="text-2xl md:text-3xl font-semibold mb-4 group-hover:text-brass transition-colors"> {post.excerpt && <p className="text-lg text-espresso/80 line-clamp-3">{post.excerpt}</p>}
{post.title}
</h2>
{post.excerpt && (
<p className="text-lg text-espresso/80 line-clamp-3">
{post.excerpt}
</p>
)}
<span className="inline-flex items-center gap-2 mt-6 text-brass font-medium group-hover:gap-3 transition-all"> <span className="inline-flex items-center gap-2 mt-6 text-brass font-medium group-hover:gap-3 transition-all">
Weiterlesen Weiterlesen
<svg <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
xmlns="http://www.w3.org/2000/svg" <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg> </svg>
</span> </span>
</div> </div>
@ -125,49 +84,24 @@ function FeaturedPostCard({ post }: { post: Post }) {
} }
function PostCard({ post }: { post: Post }) { function PostCard({ post }: { post: Post }) {
const imageUrl = getImageUrl(post.featuredImage) const imageUrl = getMediaUrl(post.featuredImage)
const series = post.series as Series | undefined const categories = resolveRelationArray<Category>(post.categories)
return ( return (
<Link href={`/blog/${post.slug}`} className="group block"> <Link href={`/blog/${post.slug}`} className="group block">
<article className="bg-ivory border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"> <article className="bg-ivory border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl">
{/* Image */}
<div className="relative aspect-[16/10] bg-warm-gray"> <div className="relative aspect-[16/10] bg-warm-gray">
{imageUrl && ( {imageUrl && <Image src={imageUrl} alt={getMediaAlt(post.featuredImage, post.title)} fill className="object-cover transition-transform duration-500 group-hover:scale-105" />}
<Image
src={imageUrl}
alt={post.featuredImage?.alt || post.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
</div> </div>
{/* Content */}
<div className="p-6"> <div className="p-6">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
{series && ( {categories[0] && <span className="text-xs text-brass font-medium">{categories[0].name}</span>}
<SeriesPill series={series.slug} size="sm"> <time dateTime={post.publishedAt || post.createdAt} className="text-xs text-espresso/60">
{series.title}
</SeriesPill>
)}
<time
dateTime={post.publishedAt || post.createdAt}
className="text-xs text-espresso/60"
>
{formatDate(post.publishedAt || post.createdAt)} {formatDate(post.publishedAt || post.createdAt)}
</time> </time>
</div> </div>
<h3 className="font-semibold text-lg mb-2 group-hover:text-brass transition-colors line-clamp-2">{post.title}</h3>
<h3 className="font-semibold text-lg mb-2 group-hover:text-brass transition-colors line-clamp-2"> {post.excerpt && <p className="text-espresso/80 text-sm line-clamp-2">{post.excerpt}</p>}
{post.title}
</h3>
{post.excerpt && (
<p className="text-espresso/80 text-sm line-clamp-2">
{post.excerpt}
</p>
)}
</div> </div>
</article> </article>
</Link> </Link>

View file

@ -1,6 +1,7 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getPage, getSiteSettings } from '@/lib/api' import { getPage, getSiteSettings } from '@/lib/api'
import { getMediaUrl } from '@/lib/payload-helpers'
import { BlockRenderer } from '@/components/blocks' import { BlockRenderer } from '@/components/blocks'
import { ComingSoonWrapper } from './ComingSoonWrapper' import { ComingSoonWrapper } from './ComingSoonWrapper'
@ -16,10 +17,11 @@ export async function generateMetadata(): Promise<Metadata> {
} }
} }
const title = page.meta?.title || page.title || 'Kommt bald' const title = page.seo?.metaTitle || page.title || 'Kommt bald'
const description = const description =
page.meta?.description || page.seo?.metaDescription ||
`${settings?.siteName || 'BlogWoman'} - Kommt bald. Sei dabei, wenn es losgeht.` `${settings?.siteName || 'BlogWoman'} - Kommt bald. Sei dabei, wenn es losgeht.`
const ogImage = getMediaUrl(page.seo?.ogImage)
return { return {
title, title,
@ -30,7 +32,7 @@ export async function generateMetadata(): Promise<Metadata> {
type: 'website', type: 'website',
locale: 'de_DE', locale: 'de_DE',
siteName: settings?.siteName || 'BlogWoman', siteName: settings?.siteName || 'BlogWoman',
images: page.meta?.image?.url ? [{ url: page.meta.image.url }] : undefined, images: ogImage ? [{ url: ogImage }] : undefined,
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',

View file

@ -1,9 +1,9 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import Image from 'next/image' import Image from 'next/image'
import { getFavorites } from '@/lib/api' import { getFavorites } from '@/lib/api'
import { getImageUrl } from '@/lib/utils' import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import { Badge } from '@/components/ui' import { Badge } from '@/components/ui'
import type { Favorite } from '@/lib/types' import type { Favorite } from '@c2s/payload-contracts/types'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Favoriten', title: 'Favoriten',
@ -21,28 +21,21 @@ const badgeLabels: Record<string, string> = {
const categoryLabels: Record<string, string> = { const categoryLabels: Record<string, string> = {
fashion: 'Mode', fashion: 'Mode',
beauty: 'Beauty', beauty: 'Beauty',
lifestyle: 'Lifestyle', travel: 'Reisen',
home: 'Zuhause', home: 'Zuhause',
tech: 'Tech', tech: 'Tech',
books: 'Bücher',
} }
export default async function FavoritenPage() { export default async function FavoritenPage() {
const favoritesData = await getFavorites({ limit: 50 }) const favoritesData = await getFavorites({ limit: 50 })
const favorites = favoritesData.docs const favorites = favoritesData.docs
// Group by category const groupedFavorites = favorites.reduce<Record<string, Favorite[]>>((acc, fav) => {
const groupedFavorites = favorites.reduce<Record<string, Favorite[]>>( const category = fav.category || 'other'
(acc, fav) => { if (!acc[category]) acc[category] = []
const category = fav.category || 'other' acc[category].push(fav)
if (!acc[category]) { return acc
acc[category] = [] }, {})
}
acc[category].push(fav)
return acc
},
{}
)
const categories = Object.keys(groupedFavorites) const categories = Object.keys(groupedFavorites)
@ -51,9 +44,7 @@ export default async function FavoritenPage() {
<section className="py-16 md:py-24"> <section className="py-16 md:py-24">
<div className="container text-center"> <div className="container text-center">
<h1 className="mb-4">Favoriten</h1> <h1 className="mb-4">Favoriten</h1>
<p className="text-lg text-espresso/80"> <p className="text-lg text-espresso/80">Noch keine Favoriten vorhanden.</p>
Noch keine Favoriten vorhanden.
</p>
</div> </div>
</section> </section>
) )
@ -61,28 +52,21 @@ export default async function FavoritenPage() {
return ( return (
<> <>
{/* Hero */}
<section className="py-16 md:py-24 bg-soft-white"> <section className="py-16 md:py-24 bg-soft-white">
<div className="container text-center"> <div className="container text-center">
<h1 className="mb-4">Meine Favoriten</h1> <h1 className="mb-4">Meine Favoriten</h1>
<p className="text-lg text-espresso/80 max-w-2xl mx-auto"> <p className="text-lg text-espresso/80 max-w-2xl mx-auto">
Produkte, die ich liebe und guten Gewissens empfehlen kann. Von Produkte, die ich liebe und guten Gewissens empfehlen kann. Von Fashion-Klassikern bis zu Lifestyle-Essentials.
Fashion-Klassikern bis zu Lifestyle-Essentials.
</p> </p>
</div> </div>
</section> </section>
{/* Category Navigation */}
{categories.length > 1 && ( {categories.length > 1 && (
<nav className="sticky top-16 z-30 bg-ivory border-b border-warm-gray py-4"> <nav className="sticky top-16 z-30 bg-ivory border-b border-warm-gray py-4">
<div className="container"> <div className="container">
<div className="flex gap-4 overflow-x-auto pb-2 -mb-2 scrollbar-hide"> <div className="flex gap-4 overflow-x-auto pb-2 -mb-2 scrollbar-hide">
{categories.map((category) => ( {categories.map((category) => (
<a <a key={category} href={`#${category}`} className="flex-shrink-0 px-4 py-2 rounded-full bg-soft-white border border-warm-gray text-sm font-medium hover:bg-brass hover:text-soft-white hover:border-brass transition-colors">
key={category}
href={`#${category}`}
className="flex-shrink-0 px-4 py-2 rounded-full bg-soft-white border border-warm-gray text-sm font-medium hover:bg-brass hover:text-soft-white hover:border-brass transition-colors"
>
{categoryLabels[category] || category} {categoryLabels[category] || category}
</a> </a>
))} ))}
@ -91,53 +75,27 @@ export default async function FavoritenPage() {
</nav> </nav>
)} )}
{/* Favorites by Category */}
{categories.map((category) => ( {categories.map((category) => (
<section <section key={category} id={category} className="py-16 md:py-20 scroll-mt-32">
key={category}
id={category}
className="py-16 md:py-20 scroll-mt-32"
>
<div className="container"> <div className="container">
<h2 className="text-2xl font-semibold mb-8"> <h2 className="text-2xl font-semibold mb-8">{categoryLabels[category] || category}</h2>
{categoryLabels[category] || category}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{groupedFavorites[category].map((favorite) => ( {groupedFavorites[category].map((favorite) => <FavoriteCard key={favorite.id} favorite={favorite} />)}
<FavoriteCard key={favorite.id} favorite={favorite} />
))}
</div> </div>
</div> </div>
</section> </section>
))} ))}
{/* Affiliate Disclosure */}
<section className="py-12 bg-soft-white"> <section className="py-12 bg-soft-white">
<div className="container"> <div className="container">
<div className="max-w-2xl mx-auto flex items-start gap-4 p-6 bg-brass/10 rounded-xl"> <div className="max-w-2xl mx-auto flex items-start gap-4 p-6 bg-brass/10 rounded-xl">
<svg <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 text-brass flex-shrink-0 mt-0.5">
xmlns="http://www.w3.org/2000/svg" <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" />
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6 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> </svg>
<div> <div>
<h3 className="font-semibold mb-2">Hinweis zu Affiliate-Links</h3> <h3 className="font-semibold mb-2">Hinweis zu Affiliate-Links</h3>
<p className="text-espresso/80"> <p className="text-espresso/80">
Einige Links auf dieser Seite sind Affiliate-Links. Das bedeutet, 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.
dass ich eine kleine Provision erhalte, wenn du über diese Links
einkaufst - ohne Mehrkosten für dich. So kannst du meine Arbeit
unterstützen, während du Produkte entdeckst, die ich wirklich
liebe.
</p> </p>
</div> </div>
</div> </div>
@ -148,86 +106,35 @@ export default async function FavoritenPage() {
} }
function FavoriteCard({ favorite }: { favorite: Favorite }) { function FavoriteCard({ favorite }: { favorite: Favorite }) {
const imageUrl = getImageUrl(favorite.image) const imageUrl = getMediaUrl(favorite.image)
return ( return (
<a <a href={favorite.affiliateUrl} target="_blank" rel="noopener noreferrer sponsored" className="group block">
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 h-full flex flex-col"> <article className="bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-full flex flex-col">
{/* Image */}
<div className="relative aspect-square bg-ivory"> <div className="relative aspect-square bg-ivory">
{imageUrl && ( {imageUrl && <Image src={imageUrl} alt={getMediaAlt(favorite.image, favorite.title)} fill className="object-cover" />}
<Image
src={imageUrl}
alt={favorite.image?.alt || favorite.title}
fill
className="object-cover"
/>
)}
{/* Badge */}
{favorite.badge && ( {favorite.badge && (
<div className="absolute top-3 left-3"> <div className="absolute top-3 left-3">
<Badge <Badge variant={favorite.badge === 'new' ? 'new' : favorite.badge === 'bestseller' ? 'popular' : 'default'}>
variant={
favorite.badge === 'new'
? 'new'
: favorite.badge === 'bestseller'
? 'popular'
: 'default'
}
>
{badgeLabels[favorite.badge]} {badgeLabels[favorite.badge]}
</Badge> </Badge>
</div> </div>
)} )}
</div> </div>
{/* Content */}
<div className="p-5 flex-1 flex flex-col"> <div className="p-5 flex-1 flex flex-col">
{favorite.category && ( {favorite.category && <p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2">{categoryLabels[favorite.category] || favorite.category}</p>}
<p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2"> <h3 className="font-headline text-lg font-semibold text-espresso mb-2 group-hover:text-brass transition-colors">{favorite.title}</h3>
{categoryLabels[favorite.category] || favorite.category} {favorite.description && <p className="text-sm text-espresso/70 line-clamp-2 mb-4 flex-1">{favorite.description}</p>}
</p>
)}
<h3 className="font-headline text-lg font-semibold text-espresso mb-2 group-hover:text-brass transition-colors">
{favorite.title}
</h3>
{favorite.description && (
<p className="text-sm text-espresso/70 line-clamp-2 mb-4 flex-1">
{favorite.description}
</p>
)}
{/* Footer */}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-warm-gray"> <div className="flex items-center justify-between mt-auto pt-4 border-t border-warm-gray">
{favorite.price && ( {favorite.price != null && (
<span className="font-semibold text-espresso"> <span className="font-semibold text-espresso">
{favorite.price} {typeof favorite.price === 'number' ? `${favorite.price.toFixed(2)}` : favorite.price}
</span> </span>
)} )}
<span className="inline-flex items-center gap-1 text-sm font-medium text-brass group-hover:gap-2 transition-all"> <span className="inline-flex items-center gap-1 text-sm font-medium text-brass group-hover:gap-2 transition-all">
Ansehen Ansehen
<svg <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-4 h-4">
xmlns="http://www.w3.org/2000/svg" <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" />
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> </svg>
</span> </span>
</div> </div>

View file

@ -2,7 +2,8 @@ import type { Metadata } from 'next'
import { Playfair_Display, Inter } from 'next/font/google' import { Playfair_Display, Inter } from 'next/font/google'
import { Header, Footer } from '@/components/layout' import { Header, Footer } from '@/components/layout'
import { UmamiScript } from '@/components/analytics' import { UmamiScript } from '@/components/analytics'
import { getSeoSettings, getSiteSettings, getNavigation } from '@/lib/api' import { getSeoSettings, getSiteSettings, getNavigation, getSocialLinks } from '@/lib/api'
import { getMediaUrl } from '@/lib/payload-helpers'
import './globals.css' import './globals.css'
const playfair = Playfair_Display({ const playfair = Playfair_Display({
@ -25,10 +26,10 @@ export async function generateMetadata(): Promise<Metadata> {
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const defaultDescription = const defaultDescription =
seoSettings?.metaDefaults?.defaultDescription || seoSettings?.metaDefaults?.defaultDescription ||
settings?.siteDescription || settings?.seo?.defaultMetaDescription ||
'Lifestyle-Blog für moderne Frauen' 'Lifestyle-Blog fuer moderne Frauen'
const defaultImage = seoSettings?.metaDefaults?.defaultImage?.url const defaultImage = getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage) || getMediaUrl(settings?.seo?.defaultOgImage)
const canIndex = seoSettings?.robots?.indexing !== false const canIndex = seoSettings?.robots?.allowIndexing !== false
const verificationOther: Record<string, string> = {} const verificationOther: Record<string, string> = {}
if (seoSettings?.verification?.bing) { if (seoSettings?.verification?.bing) {
@ -71,16 +72,15 @@ export default async function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode children: React.ReactNode
}>) { }>) {
// Fetch navigation (one doc per tenant) and settings in parallel const [navigation, settings, socialLinks] = await Promise.all([
const [navigation, settings] = await Promise.all([
getNavigation(), getNavigation(),
getSiteSettings(), getSiteSettings(),
getSocialLinks(),
]) ])
return ( return (
<html lang="de" className={`${playfair.variable} ${inter.variable}`}> <html lang="de" className={`${playfair.variable} ${inter.variable}`}>
<body className="font-body text-espresso bg-ivory antialiased"> <body className="font-body text-espresso bg-ivory antialiased">
{/* Skip to main content link for accessibility */}
<a <a
href="#main-content" 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" 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"
@ -90,7 +90,7 @@ export default async function RootLayout({
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<Header mainMenu={navigation?.mainMenu ?? null} settings={settings} /> <Header mainMenu={navigation?.mainMenu ?? null} settings={settings} />
<main id="main-content" className="flex-1">{children}</main> <main id="main-content" className="flex-1">{children}</main>
<Footer footerMenu={navigation?.footerMenu ?? null} settings={settings} /> <Footer footerMenu={navigation?.footerMenu ?? null} settings={settings} socialLinks={socialLinks} />
</div> </div>
<UmamiScript /> <UmamiScript />
</body> </body>

View file

@ -2,7 +2,8 @@ import Image from 'next/image'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getPost, getSeoSettings, getSiteSettings } from '@/lib/api' import { getPost, getSeoSettings, getSiteSettings } from '@/lib/api'
import { formatDate, getImageUrl } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import { RichTextRenderer } from '@/components/blocks' import { RichTextRenderer } from '@/components/blocks'
import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData' import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
@ -20,14 +21,14 @@ export async function generateMetadata({ params }: NewsPostPageProps): Promise<M
if (!post) return {} if (!post) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = post.meta?.title || post.title const titleBase = post.seo?.metaTitle || post.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description = const description =
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription post.seo?.metaDescription || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
const image = const image =
post.meta?.image?.url || getMediaUrl(post.seo?.ogImage) ||
post.featuredImage?.url || getMediaUrl(post.featuredImage) ||
seoSettings?.metaDefaults?.defaultImage?.url getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
return { return {
title, title,
@ -44,10 +45,6 @@ export async function generateMetadata({ params }: NewsPostPageProps): Promise<M
description: description || undefined, description: description || undefined,
images: image ? [image] : undefined, images: image ? [image] : undefined,
}, },
robots: {
index: !post.meta?.noIndex,
follow: !post.meta?.noFollow,
},
} }
} }
@ -62,7 +59,7 @@ export default async function NewsPostPage({ params }: NewsPostPageProps) {
notFound() notFound()
} }
const imageUrl = getImageUrl(post.featuredImage) const imageUrl = getMediaUrl(post.featuredImage)
const blogSchema = generateBlogPostingSchema(post, settings) const blogSchema = generateBlogPostingSchema(post, settings)
const breadcrumbSchema = generateBreadcrumbSchema([ const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' }, { name: 'Startseite', url: '/' },
@ -94,7 +91,7 @@ export default async function NewsPostPage({ params }: NewsPostPageProps) {
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10"> <div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image <Image
src={imageUrl} src={imageUrl}
alt={post.featuredImage?.alt || post.title} alt={getMediaAlt(post.featuredImage, post.title)}
fill fill
className="object-cover" className="object-cover"
priority priority

View file

@ -2,6 +2,7 @@ import { Metadata } from 'next'
import { getPage, getSeoSettings, getSiteSettings } from '@/lib/api' import { getPage, getSeoSettings, getSiteSettings } from '@/lib/api'
import { BlockRenderer } from '@/components/blocks' import { BlockRenderer } from '@/components/blocks'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getMediaUrl } from '@/lib/payload-helpers'
import { generateOrganizationSchema, generateWebSiteSchema } from '@/lib/structuredData' import { generateOrganizationSchema, generateWebSiteSchema } from '@/lib/structuredData'
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
@ -15,12 +16,12 @@ export async function generateMetadata(): Promise<Metadata> {
} }
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = page.meta?.title || page.title const titleBase = page.seo?.metaTitle || page.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description = const description =
page.meta?.description || seoSettings?.metaDefaults?.defaultDescription page.seo?.metaDescription || seoSettings?.metaDefaults?.defaultDescription
const image = const image =
page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url getMediaUrl(page.seo?.ogImage) || getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
return { return {
title, title,
@ -37,10 +38,6 @@ export async function generateMetadata(): Promise<Metadata> {
description: description || undefined, description: description || undefined,
images: image ? [image] : undefined, images: image ? [image] : undefined,
}, },
robots: {
index: !page.meta?.noIndex,
follow: !page.meta?.noFollow,
},
} }
} }

View file

@ -2,7 +2,8 @@ import Image from 'next/image'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getPost, getSeoSettings, getSiteSettings } from '@/lib/api' import { getPost, getSeoSettings, getSiteSettings } from '@/lib/api'
import { formatDate, getImageUrl } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import { RichTextRenderer } from '@/components/blocks' import { RichTextRenderer } from '@/components/blocks'
import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData' import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
@ -20,14 +21,14 @@ export async function generateMetadata({ params }: PressPostPageProps): Promise<
if (!post) return {} if (!post) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = post.meta?.title || post.title const titleBase = post.seo?.metaTitle || post.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description = const description =
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription post.seo?.metaDescription || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
const image = const image =
post.meta?.image?.url || getMediaUrl(post.seo?.ogImage) ||
post.featuredImage?.url || getMediaUrl(post.featuredImage) ||
seoSettings?.metaDefaults?.defaultImage?.url getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
return { return {
title, title,
@ -44,10 +45,6 @@ export async function generateMetadata({ params }: PressPostPageProps): Promise<
description: description || undefined, description: description || undefined,
images: image ? [image] : undefined, images: image ? [image] : undefined,
}, },
robots: {
index: !post.meta?.noIndex,
follow: !post.meta?.noFollow,
},
} }
} }
@ -62,7 +59,7 @@ export default async function PressPostPage({ params }: PressPostPageProps) {
notFound() notFound()
} }
const imageUrl = getImageUrl(post.featuredImage) const imageUrl = getMediaUrl(post.featuredImage)
const blogSchema = generateBlogPostingSchema(post, settings) const blogSchema = generateBlogPostingSchema(post, settings)
const breadcrumbSchema = generateBreadcrumbSchema([ const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' }, { name: 'Startseite', url: '/' },
@ -94,7 +91,7 @@ export default async function PressPostPage({ params }: PressPostPageProps) {
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10"> <div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image <Image
src={imageUrl} src={imageUrl}
alt={post.featuredImage?.alt || post.title} alt={getMediaAlt(post.featuredImage, post.title)}
fill fill
className="object-cover" className="object-cover"
priority priority

View file

@ -3,7 +3,7 @@ import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getSeoSettings, getSeriesBySlug } from '@/lib/api' import { getSeoSettings, getSeriesBySlug } from '@/lib/api'
import { getImageUrl } from '@/lib/utils' import { getMediaUrl } from '@/lib/payload-helpers'
import { RichTextRenderer } from '@/components/blocks' import { RichTextRenderer } from '@/components/blocks'
import { generateBreadcrumbSchema, generateSeriesSchema } from '@/lib/structuredData' import { generateBreadcrumbSchema, generateSeriesSchema } from '@/lib/structuredData'
@ -21,16 +21,12 @@ export async function generateMetadata({ params }: SeriesPageProps): Promise<Met
if (!series) return {} if (!series) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || '' const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = series.meta?.title || series.title const title = titleSuffix ? `${series.title} ${titleSuffix}` : series.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase const description = seoSettings?.metaDefaults?.defaultDescription || undefined
const description =
series.meta?.description ||
seoSettings?.metaDefaults?.defaultDescription
const image = const image =
series.meta?.image?.url || getMediaUrl(series.coverImage) ||
series.coverImage?.url || getMediaUrl(series.logo) ||
series.logo?.url || getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
seoSettings?.metaDefaults?.defaultImage?.url
return { return {
title, title,
@ -47,10 +43,6 @@ export async function generateMetadata({ params }: SeriesPageProps): Promise<Met
description: description || undefined, description: description || undefined,
images: image ? [image] : undefined, images: image ? [image] : undefined,
}, },
robots: {
index: !series.meta?.noIndex,
follow: !series.meta?.noFollow,
},
} }
} }
@ -62,7 +54,7 @@ export default async function SeriesPage({ params }: SeriesPageProps) {
notFound() notFound()
} }
const coverUrl = getImageUrl(series.coverImage) || getImageUrl(series.logo) const coverUrl = getMediaUrl(series.coverImage) || getMediaUrl(series.logo)
const breadcrumbSchema = generateBreadcrumbSchema([ const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' }, { name: 'Startseite', url: '/' },
{ name: 'Serien', url: '/serien' }, { name: 'Serien', url: '/serien' },

View file

@ -2,9 +2,9 @@ import { Metadata } from 'next'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { getSeries } from '@/lib/api' import { getSeries } from '@/lib/api'
import { getImageUrl } from '@/lib/utils' import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import { RichTextRenderer } from '@/components/blocks' import { RichTextRenderer } from '@/components/blocks'
import type { Series } from '@/lib/types' import type { Series } from '@c2s/payload-contracts/types'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Serien', title: 'Serien',
@ -20,9 +20,7 @@ export default async function SerienPage() {
<section className="py-16 md:py-24"> <section className="py-16 md:py-24">
<div className="container text-center"> <div className="container text-center">
<h1 className="mb-4">Serien</h1> <h1 className="mb-4">Serien</h1>
<p className="text-lg text-espresso/80"> <p className="text-lg text-espresso/80">Noch keine Serien vorhanden.</p>
Noch keine Serien vorhanden.
</p>
</div> </div>
</section> </section>
) )
@ -30,24 +28,18 @@ export default async function SerienPage() {
return ( return (
<> <>
{/* Hero */}
<section className="py-16 md:py-24 bg-soft-white"> <section className="py-16 md:py-24 bg-soft-white">
<div className="container text-center"> <div className="container text-center">
<h1 className="mb-4">Meine Serien</h1> <h1 className="mb-4">Meine Serien</h1>
<p className="text-lg text-espresso/80 max-w-2xl mx-auto"> <p className="text-lg text-espresso/80 max-w-2xl mx-auto">
Entdecke meine YouTube-Serien zu verschiedenen Themen rund um Entdecke meine YouTube-Serien zu verschiedenen Themen rund um Lifestyle, Mode und mehr.
Lifestyle, Mode und mehr.
</p> </p>
</div> </div>
</section> </section>
{/* Series Grid */}
<section className="py-16 md:py-20"> <section className="py-16 md:py-20">
<div className="container"> <div className="container">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{allSeries.map((series) => ( {allSeries.map((series) => <SeriesCard key={series.id} series={series} />)}
<SeriesCard key={series.id} series={series} />
))}
</div> </div>
</div> </div>
</section> </section>
@ -56,66 +48,27 @@ export default async function SerienPage() {
} }
function SeriesCard({ series }: { series: Series }) { function SeriesCard({ series }: { series: Series }) {
const _imageUrl = getImageUrl(series.coverImage) || getImageUrl(series.logo) const coverUrl = getMediaUrl(series.coverImage)
const logoUrl = getMediaUrl(series.logo)
return ( return (
<Link href={`/serien/${series.slug}`} className="group block"> <Link href={`/serien/${series.slug}`} className="group block">
<article <article className="relative rounded-2xl overflow-hidden min-h-[320px] flex flex-col justify-end" style={{ backgroundColor: series.brandColor || '#C6A47E' }}>
className="relative rounded-2xl overflow-hidden min-h-[320px] flex flex-col justify-end" {coverUrl && <Image src={coverUrl} alt="" fill className="object-cover opacity-40 transition-opacity duration-300 group-hover:opacity-50" />}
style={{ backgroundColor: series.brandColor || '#C6A47E' }}
>
{/* Background Image */}
{series.coverImage && (
<Image
src={series.coverImage.url}
alt=""
fill
className="object-cover opacity-40 transition-opacity duration-300 group-hover:opacity-50"
/>
)}
{/* Content */}
<div className="relative z-10 p-8"> <div className="relative z-10 p-8">
{/* Logo */} {logoUrl && (
{series.logo && (
<div className="relative w-32 h-14 mb-4"> <div className="relative w-32 h-14 mb-4">
<Image <Image src={logoUrl} alt={getMediaAlt(series.logo)} fill className="object-contain object-left" />
src={series.logo.url}
alt=""
fill
className="object-contain object-left"
/>
</div> </div>
)} )}
<h2 className="text-2xl font-semibold text-soft-white mb-3">{series.title}</h2>
{/* Title */}
<h2 className="text-2xl font-semibold text-soft-white mb-3">
{series.title}
</h2>
{/* Description */}
{series.description && ( {series.description && (
<div className="text-soft-white/80 line-clamp-2 mb-4"> <div className="text-soft-white/80 line-clamp-2 mb-4"><RichTextRenderer content={series.description} /></div>
<RichTextRenderer content={series.description} />
</div>
)} )}
{/* Link */}
<span className="inline-flex items-center gap-2 text-soft-white font-medium group-hover:gap-3 transition-all"> <span className="inline-flex items-center gap-2 text-soft-white font-medium group-hover:gap-3 transition-all">
Zur Serie Zur Serie
<svg <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
xmlns="http://www.w3.org/2000/svg" <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg> </svg>
</span> </span>
</div> </div>

View file

@ -1,56 +1,53 @@
import Image from 'next/image'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { CTABlock as CTABlockType } from '@/lib/types' import type { BlockByType } from '@c2s/payload-contracts/types'
type CTABlockProps = Omit<CTABlockType, 'blockType'> type CTABlockProps = Omit<BlockByType<'cta-block'>, 'blockType' | 'blockName'>
export function CTABlock({ export function CTABlock({
heading, headline,
subheading, description,
buttonText, buttons,
buttonLink, backgroundColor = 'dark',
backgroundColor = 'brass',
backgroundImage,
}: CTABlockProps) { }: CTABlockProps) {
const bgClasses = { const bgClasses: Record<string, string> = {
brass: 'bg-brass', dark: 'bg-espresso',
espresso: 'bg-espresso', light: 'bg-ivory',
bordeaux: 'bg-bordeaux', accent: 'bg-brass',
} }
return ( const firstButton = buttons?.[0]
<section className={cn('relative py-16 md:py-24', bgClasses[backgroundColor])}>
{/* Background Image */}
{backgroundImage?.url && (
<div className="absolute inset-0">
<Image
src={backgroundImage.url}
alt=""
fill
className="object-cover opacity-20"
/>
</div>
)}
return (
<section className={cn('relative py-16 md:py-24', bgClasses[backgroundColor || 'dark'])}>
<div className="container relative z-10"> <div className="container relative z-10">
<div className="max-w-3xl mx-auto text-center"> <div className="max-w-3xl mx-auto text-center">
<h2 className="text-soft-white mb-4">{heading}</h2> <h2 className={cn(backgroundColor === 'light' ? 'text-espresso' : 'text-soft-white', 'mb-4')}>
{headline}
</h2>
{subheading && ( {description && (
<p className="text-soft-white/80 text-lg md:text-xl mb-8"> <p className={cn(
{subheading} 'text-lg md:text-xl mb-8',
backgroundColor === 'light' ? 'text-espresso/80' : 'text-soft-white/80'
)}>
{description}
</p> </p>
)} )}
<Button {firstButton && (
href={buttonLink} <Button
variant="secondary" href={firstButton.link}
size="lg" variant="secondary"
className="border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso" size="lg"
> className={cn(
{buttonText} backgroundColor !== 'light' &&
</Button> 'border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso'
)}
>
{firstButton.text}
</Button>
)}
</div> </div>
</div> </div>
</section> </section>

View file

@ -1,68 +1,63 @@
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { CardGridBlock as CardGridBlockType } from '@/lib/types' import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import type { BlockByType } from '@c2s/payload-contracts/types'
type CardGridBlockProps = Omit<CardGridBlockType, 'blockType'> type CardGridBlockProps = Omit<BlockByType<'card-grid-block'>, 'blockType' | 'blockName'>
export function CardGridBlock({ export function CardGridBlock({
title, headline,
subtitle,
cards, cards,
columns = 3, columns = '3',
}: CardGridBlockProps) { }: CardGridBlockProps) {
const columnClasses = { const columnClasses: Record<string, string> = {
2: 'md:grid-cols-2', '2': 'md:grid-cols-2',
3: 'md:grid-cols-2 lg:grid-cols-3', '3': 'md:grid-cols-2 lg:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-4', '4': 'md:grid-cols-2 lg:grid-cols-4',
} }
return ( return (
<section className="py-16 md:py-20"> <section className="py-16 md:py-20">
<div className="container"> <div className="container">
{/* Section Header */} {/* Section Header */}
{(title || subtitle) && ( {headline && (
<div className="text-center max-w-2xl mx-auto mb-12"> <div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>} <h2 className="mb-4">{headline}</h2>
{subtitle && (
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div> </div>
)} )}
{/* Card Grid */} {/* Card Grid */}
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}> {cards && cards.length > 0 && (
{cards.map((card, index) => ( <div className={cn('grid grid-cols-1 gap-6', columnClasses[columns || '3'])}>
<CardItem key={index} {...card} /> {cards.map((card, index) => (
))} <CardItem key={card.id || index} card={card} />
</div> ))}
</div>
)}
</div> </div>
</section> </section>
) )
} }
interface CardItemProps { type CardData = NonNullable<CardGridBlockProps['cards']>[number]
title: string
description?: string function CardItem({ card }: { card: CardData }) {
image?: { url: string; alt?: string } const imgUrl = getMediaUrl(card.image)
link?: string
icon?: string
}
function CardItem({ title, description, image, link, icon }: CardItemProps) {
const content = ( const content = (
<div <div
className={cn( className={cn(
'bg-soft-white border border-warm-gray rounded-2xl overflow-hidden', 'bg-soft-white border border-warm-gray rounded-2xl overflow-hidden',
'transition-all duration-300 ease-out', 'transition-all duration-300 ease-out',
link && 'hover:-translate-y-1 hover:shadow-xl cursor-pointer' card.link && 'hover:-translate-y-1 hover:shadow-xl cursor-pointer'
)} )}
> >
{image?.url && ( {imgUrl && (
<div className="relative aspect-video"> <div className="relative aspect-video">
<Image <Image
src={image.url} src={imgUrl}
alt={image.alt || title} alt={getMediaAlt(card.image, card.title)}
fill fill
className="object-cover" className="object-cover"
/> />
@ -70,24 +65,24 @@ function CardItem({ title, description, image, link, icon }: CardItemProps) {
)} )}
<div className="p-6"> <div className="p-6">
{icon && ( {card.icon && (
<div className="w-12 h-12 rounded-full bg-brass/10 flex items-center justify-center mb-4"> <div className="w-12 h-12 rounded-full bg-brass/10 flex items-center justify-center mb-4">
<span className="text-2xl">{icon}</span> <span className="text-2xl">{card.icon}</span>
</div> </div>
)} )}
<h3 className="text-xl font-semibold mb-2">{title}</h3> <h3 className="text-xl font-semibold mb-2">{card.title}</h3>
{description && ( {card.description && (
<p className="text-espresso/80">{description}</p> <p className="text-espresso/80">{card.description}</p>
)} )}
</div> </div>
</div> </div>
) )
if (link) { if (card.link) {
return ( return (
<Link href={link} className="block"> <Link href={card.link} className="block">
{content} {content}
</Link> </Link>
) )

View file

@ -1,21 +1,21 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { Button, Input, Textarea } from '@/components/ui' import { Button, Input } from '@/components/ui'
import { submitContactForm } from '@/lib/api' import { submitContactForm } from '@/lib/api'
import type { ContactFormBlock as ContactFormBlockType } from '@/lib/types' import type { BlockByType } from '@c2s/payload-contracts/types'
type ContactFormBlockProps = Omit<ContactFormBlockType, 'blockType'> type ContactFormBlockProps = Omit<BlockByType<'contact-form-block'>, 'blockType' | 'blockName'>
export function ContactFormBlock({ export function ContactFormBlock({
title, headline,
subtitle, description,
formId, form: formRef,
showName = true,
showPhone = false, showPhone = false,
showSubject = true,
successMessage = 'Vielen Dank für Ihre Nachricht! Wir melden uns zeitnah bei Ihnen.', successMessage = 'Vielen Dank für Ihre Nachricht! Wir melden uns zeitnah bei Ihnen.',
}: ContactFormBlockProps) { }: ContactFormBlockProps) {
const formId = typeof formRef === 'object' && formRef !== null ? formRef.id : (formRef as number | undefined)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
email: '', email: '',
@ -26,26 +26,16 @@ export function ContactFormBlock({
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
function handleChange( function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }))
) {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}))
} }
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setStatus('loading') setStatus('loading')
setErrorMessage('') setErrorMessage('')
try { try {
const result = await submitContactForm({ const result = await submitContactForm({ ...formData, formId })
...formData,
formId,
})
if (result.success) { if (result.success) {
setStatus('success') setStatus('success')
setFormData({ name: '', email: '', phone: '', subject: '', message: '' }) setFormData({ name: '', email: '', phone: '', subject: '', message: '' })
@ -63,111 +53,36 @@ export function ContactFormBlock({
<section className="py-16 md:py-20"> <section className="py-16 md:py-20">
<div className="container"> <div className="container">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
{/* Header */} {(headline || description) && (
{(title || subtitle) && (
<div className="text-center mb-10"> <div className="text-center mb-10">
{title && <h2 className="mb-4">{title}</h2>} {headline && <h2 className="mb-4">{headline}</h2>}
{subtitle && ( {description && <p className="text-lg text-espresso/80">{description}</p>}
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div> </div>
)} )}
{/* Success Message */}
{status === 'success' ? ( {status === 'success' ? (
<div className="p-6 bg-success/10 text-success rounded-xl text-center"> <div className="p-6 bg-success/10 text-success rounded-xl text-center">
<svg <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 mx-auto mb-4">
xmlns="http://www.w3.org/2000/svg" <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-12 h-12 mx-auto mb-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<p className="font-medium text-lg">{successMessage}</p> <p className="font-medium text-lg">{successMessage}</p>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Name & Email Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{showName && ( <Input label="Name" name="name" value={formData.name} onChange={handleChange} required disabled={status === 'loading'} />
<Input <Input label="E-Mail" name="email" type="email" value={formData.email} onChange={handleChange} required disabled={status === 'loading'} />
label="Name"
name="name"
value={formData.name}
onChange={handleChange}
required
disabled={status === 'loading'}
/>
)}
<Input
label="E-Mail"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
required
disabled={status === 'loading'}
/>
</div> </div>
{showPhone && (
{/* Phone & Subject Row */} <Input label="Telefon" name="phone" type="tel" value={formData.phone} onChange={handleChange} disabled={status === 'loading'} />
{(showPhone || showSubject) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{showPhone && (
<Input
label="Telefon"
name="phone"
type="tel"
value={formData.phone}
onChange={handleChange}
disabled={status === 'loading'}
/>
)}
{showSubject && (
<Input
label="Betreff"
name="subject"
value={formData.subject}
onChange={handleChange}
required
disabled={status === 'loading'}
/>
)}
</div>
)} )}
<Input label="Betreff" name="subject" value={formData.subject} onChange={handleChange} required disabled={status === 'loading'} />
{/* Message */} <div>
<Textarea <label htmlFor="message" className="block text-sm font-medium text-espresso mb-2">Nachricht</label>
label="Nachricht" <textarea id="message" name="message" value={formData.message} onChange={handleChange} required disabled={status === 'loading'} rows={5} className="w-full rounded-lg border border-warm-gray bg-soft-white px-4 py-3 text-espresso focus:border-brass focus:outline-none focus:ring-1 focus:ring-brass disabled:opacity-50" />
name="message" </div>
value={formData.message} {status === 'error' && <div className="p-4 bg-error/10 text-error rounded-lg">{errorMessage}</div>}
onChange={handleChange}
required
disabled={status === 'loading'}
rows={5}
/>
{/* Error Message */}
{status === 'error' && (
<div className="p-4 bg-error/10 text-error rounded-lg">
{errorMessage}
</div>
)}
{/* Submit Button */}
<div className="text-center"> <div className="text-center">
<Button <Button type="submit" size="lg" disabled={status === 'loading'}>
type="submit"
size="lg"
disabled={status === 'loading'}
>
{status === 'loading' ? 'Wird gesendet...' : 'Nachricht senden'} {status === 'loading' ? 'Wird gesendet...' : 'Nachricht senden'}
</Button> </Button>
</div> </div>

View file

@ -1,15 +1,23 @@
import type { DividerBlock as DividerBlockType } from '@/lib/types' import type { BlockByType } from '@c2s/payload-contracts/types'
type DividerBlockProps = Omit<DividerBlockType, 'blockType'> type DividerBlockProps = Omit<BlockByType<'divider-block'>, 'blockType' | 'blockName'>
export function DividerBlock({ style = 'line', spacing = 'medium' }: DividerBlockProps) {
const spacingClasses: Record<string, string> = {
small: 'py-6',
medium: 'py-12',
large: 'py-20',
}
const py = spacingClasses[spacing || 'medium']
export function DividerBlock({ style = 'line', text }: DividerBlockProps) {
if (style === 'space') { if (style === 'space') {
return <div className="py-12" /> return <div className={py} />
} }
if (style === 'dots') { if (style === 'dots') {
return ( return (
<div className="py-12 flex justify-center gap-2"> <div className={`${py} 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" /> <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" />
@ -17,23 +25,8 @@ export function DividerBlock({ style = 'line', text }: DividerBlockProps) {
) )
} }
// 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 ( return (
<div className="py-12 container"> <div className={`${py} container`}>
<div className="h-px bg-warm-gray" /> <div className="h-px bg-warm-gray" />
</div> </div>
) )

View file

@ -3,31 +3,32 @@
import { useState } from 'react' import { useState } from 'react'
import Script from 'next/script' import Script from 'next/script'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { resolveRelationArray } from '@/lib/payload-helpers'
import { RichTextRenderer } from './RichTextRenderer' import { RichTextRenderer } from './RichTextRenderer'
import type { FAQBlock as FAQBlockType, FAQ } from '@/lib/types' import type { BlockByType, Faq } from '@c2s/payload-contracts/types'
type FAQBlockProps = Omit<FAQBlockType, 'blockType'> & { type FAQBlockProps = Omit<BlockByType<'faq-block'>, 'blockType' | 'blockName'> & {
faqs?: FAQ[] faqs?: Faq[]
} }
export function FAQBlock({ export function FAQBlock({
title, title,
subtitle, subtitle,
displayMode, displayMode,
selectedFaqs, selectedFAQs,
filterCategory: _filterCategory, category: _category,
layout = 'accordion', layout = 'accordion',
expandFirst = false, expandFirst = false,
showSchema = true, enableSchemaOrg = true,
faqs: externalFaqs, faqs: externalFaqs,
}: FAQBlockProps) { }: FAQBlockProps) {
// Use selectedFaqs if displayMode is 'selected', otherwise use externalFaqs const items = displayMode === 'selected'
const items = displayMode === 'selected' ? selectedFaqs : externalFaqs ? resolveRelationArray<Faq>(selectedFAQs)
: externalFaqs || []
if (!items || items.length === 0) return null if (!items || items.length === 0) return null
// Generate JSON-LD schema data const schemaData = enableSchemaOrg
const schemaData = showSchema
? { ? {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'FAQPage', '@type': 'FAQPage',
@ -45,34 +46,23 @@ export function FAQBlock({
return ( return (
<section className="py-16 md:py-20"> <section className="py-16 md:py-20">
<div className="container"> <div className="container">
{/* Section Header */}
{(title || subtitle) && ( {(title || subtitle) && (
<div className="text-center max-w-2xl mx-auto mb-12"> <div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>} {title && <h2 className="mb-4">{title}</h2>}
{subtitle && ( {subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div> </div>
)} )}
{/* FAQ Items */}
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
{layout === 'accordion' ? ( {layout === 'accordion' ? (
<AccordionFAQ items={items} expandFirst={expandFirst} /> <AccordionFAQ items={items} expandFirst={expandFirst ?? false} />
) : layout === 'grid' ? ( ) : layout === 'grid' ? (
<GridFAQ items={items} /> <GridFAQ items={items} />
) : ( ) : (
<ListFAQ items={items} /> <ListFAQ items={items} />
)} )}
</div> </div>
{/* JSON-LD Schema using Next.js Script component for safety */}
{schemaData && ( {schemaData && (
<Script <Script id="faq-schema" type="application/ld+json" strategy="afterInteractive">
id="faq-schema"
type="application/ld+json"
strategy="afterInteractive"
>
{JSON.stringify(schemaData)} {JSON.stringify(schemaData)}
</Script> </Script>
)} )}
@ -81,60 +71,25 @@ export function FAQBlock({
) )
} }
function AccordionFAQ({ function AccordionFAQ({ items, expandFirst }: { items: Faq[]; expandFirst: boolean }) {
items, const [openIndex, setOpenIndex] = useState<number | null>(expandFirst ? 0 : null)
expandFirst,
}: {
items: FAQ[]
expandFirst: boolean
}) {
const [openIndex, setOpenIndex] = useState<number | null>(
expandFirst ? 0 : null
)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{items.map((faq, index) => ( {items.map((faq, index) => (
<div <div key={faq.id} className="border border-warm-gray rounded-xl overflow-hidden">
key={faq.id}
className="border border-warm-gray rounded-xl overflow-hidden"
>
<button <button
type="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" className="w-full px-6 py-4 flex items-center justify-between text-left bg-soft-white hover:bg-ivory transition-colors"
onClick={() => setOpenIndex(openIndex === index ? null : index)} onClick={() => setOpenIndex(openIndex === index ? null : index)}
aria-expanded={openIndex === index} aria-expanded={openIndex === index}
> >
<span className="font-headline text-lg font-medium text-espresso pr-4"> <span className="font-headline text-lg font-medium text-espresso pr-4">{faq.question}</span>
{faq.question} <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')}>
</span> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
<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> </svg>
</button> </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={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="overflow-hidden">
<div className="px-6 pb-6 pt-2"> <div className="px-6 pb-6 pt-2">
<RichTextRenderer content={faq.answer} /> <RichTextRenderer content={faq.answer} />
@ -147,7 +102,7 @@ function AccordionFAQ({
) )
} }
function ListFAQ({ items }: { items: FAQ[] }) { function ListFAQ({ items }: { items: Faq[] }) {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{items.map((faq) => ( {items.map((faq) => (
@ -160,14 +115,11 @@ function ListFAQ({ items }: { items: FAQ[] }) {
) )
} }
function GridFAQ({ items }: { items: FAQ[] }) { function GridFAQ({ items }: { items: Faq[] }) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{items.map((faq) => ( {items.map((faq) => (
<div <div key={faq.id} className="bg-soft-white border border-warm-gray rounded-xl p-6">
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> <h3 className="text-lg font-semibold mb-3">{faq.question}</h3>
<RichTextRenderer content={faq.answer} /> <RichTextRenderer content={faq.answer} />
</div> </div>
@ -176,23 +128,13 @@ function GridFAQ({ items }: { items: FAQ[] }) {
) )
} }
// Helper to extract plain text from RichText for schema function extractTextFromRichText(richText: Faq['answer']): string {
function extractTextFromRichText(richText: FAQ['answer']): string {
if (!richText?.root?.children) return '' if (!richText?.root?.children) return ''
function extractFromNode(node: Record<string, unknown>): string { function extractFromNode(node: Record<string, unknown>): string {
if (node.text) return node.text as string if (node.text) return node.text as string
const children = node.children as Record<string, unknown>[] | undefined const children = node.children as Record<string, unknown>[] | undefined
if (children) { if (children) return children.map(extractFromNode).join('')
return children.map(extractFromNode).join('')
}
return '' return ''
} }
return richText.root.children.map((node) => extractFromNode(node as Record<string, unknown>)).join(' ').trim()
return richText.root.children
.map((node) => extractFromNode(node as Record<string, unknown>))
.join(' ')
.trim()
} }

View file

@ -1,42 +1,34 @@
import Image from 'next/image' import Image from 'next/image'
import { cn, getImageUrl } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import { Badge } from '@/components/ui' import { Badge } from '@/components/ui'
import { getFavorites } from '@/lib/api' import { getFavorites } from '@/lib/api'
import type { FavoritesBlock as FavoritesBlockType, Favorite } from '@/lib/types' import type { BlockByType, Favorite } from '@c2s/payload-contracts/types'
type FavoritesBlockProps = Omit<FavoritesBlockType, 'blockType'> type FavoritesBlockProps = Omit<BlockByType<'favorites-block'>, 'blockType' | 'blockName'>
export async function FavoritesBlock({ export async function FavoritesBlock({
title, title,
subtitle, subtitle,
displayMode, category,
selectedFavorites,
filterCategory,
layout: _layout = 'grid', layout: _layout = 'grid',
columns = 3, columns = '3',
limit = 12, limit = 12,
showPrice = true, showPrice = true,
showBadge = true, showBadge = true,
}: FavoritesBlockProps) { }: FavoritesBlockProps) {
// Fetch favorites if not using selected mode const favoritesData = await getFavorites({
let items: Favorite[] = [] category: category && category !== 'all' ? category : undefined,
limit: limit ?? 12,
if (displayMode === 'selected' && selectedFavorites) { })
items = selectedFavorites const items = favoritesData.docs
} else {
const favoritesData = await getFavorites({
category: filterCategory,
limit,
})
items = favoritesData.docs
}
if (!items || items.length === 0) return null if (!items || items.length === 0) return null
const columnClasses = { const columnClasses: Record<string, string> = {
2: 'md:grid-cols-2', '2': 'md:grid-cols-2',
3: 'md:grid-cols-2 lg:grid-cols-3', '3': 'md:grid-cols-2 lg:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-4', '4': 'md:grid-cols-2 lg:grid-cols-4',
} }
const badgeLabels: Record<string, string> = { const badgeLabels: Record<string, string> = {
@ -58,136 +50,57 @@ export async function FavoritesBlock({
return ( return (
<section className="py-16 md:py-20"> <section className="py-16 md:py-20">
<div className="container"> <div className="container">
{/* Section Header */}
{(title || subtitle) && ( {(title || subtitle) && (
<div className="text-center max-w-2xl mx-auto mb-12"> <div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>} {title && <h2 className="mb-4">{title}</h2>}
{subtitle && ( {subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div> </div>
)} )}
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns || '3'])}>
{/* Favorites Grid */}
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>
{items.map((favorite) => ( {items.map((favorite) => (
<FavoriteCard <FavoriteCard key={favorite.id} favorite={favorite} showPrice={showPrice} showBadge={showBadge} badgeLabels={badgeLabels} badgeVariants={badgeVariants} />
key={favorite.id}
favorite={favorite}
showPrice={showPrice}
showBadge={showBadge}
badgeLabels={badgeLabels}
badgeVariants={badgeVariants}
/>
))} ))}
</div> </div>
{/* Affiliate Disclosure */}
<div className="mt-12 flex items-start gap-3 p-4 bg-brass/10 rounded-lg max-w-2xl mx-auto"> <div className="mt-12 flex items-start gap-3 p-4 bg-brass/10 rounded-lg max-w-2xl mx-auto">
<svg <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">
xmlns="http://www.w3.org/2000/svg" <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" />
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> </svg>
<p className="text-sm text-espresso"> <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>
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>
</div> </div>
</section> </section>
) )
} }
interface FavoriteCardProps { function FavoriteCard({ favorite, showPrice, showBadge, badgeLabels, badgeVariants }: { favorite: Favorite; showPrice?: boolean | null; showBadge?: boolean | null; badgeLabels: Record<string, string>; badgeVariants: Record<string, string> }) {
favorite: Favorite const imageUrl = getMediaUrl(favorite.image)
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 ( return (
<a <a href={favorite.affiliateUrl} target="_blank" rel="noopener noreferrer sponsored" className="group block">
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"> <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"> <div className="relative aspect-square bg-ivory">
{imageUrl && ( {imageUrl && <Image src={imageUrl} alt={getMediaAlt(favorite.image, favorite.title)} fill className="object-cover" />}
<Image
src={imageUrl}
alt={favorite.image?.alt || favorite.title}
fill
className="object-cover"
/>
)}
{/* Badge */}
{showBadge && favorite.badge && ( {showBadge && favorite.badge && (
<div className="absolute top-3 left-3"> <div className="absolute top-3 left-3">
<Badge variant={badgeVariants[favorite.badge] || 'default'}> <Badge variant={(badgeVariants[favorite.badge] || 'default') as 'new' | 'popular' | 'default'}>
{badgeLabels[favorite.badge]} {badgeLabels[favorite.badge]}
</Badge> </Badge>
</div> </div>
)} )}
</div> </div>
{/* Content */}
<div className="p-5"> <div className="p-5">
{favorite.category && ( {favorite.category && <p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2">{favorite.category}</p>}
<p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2"> <h3 className="font-headline text-lg font-semibold text-espresso mb-2 group-hover:text-brass transition-colors">{favorite.title}</h3>
{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"> <div className="flex items-center justify-between mt-4">
{showPrice && favorite.price && ( {showPrice && favorite.price != null && (
<span className="font-semibold text-espresso"> <span className="font-semibold text-espresso">
{favorite.price} {typeof favorite.price === 'number' ? `${favorite.price.toFixed(2)}` : favorite.price}
</span> </span>
)} )}
<span className="inline-flex items-center gap-1 text-sm font-medium text-brass group-hover:gap-2 transition-all"> <span className="inline-flex items-center gap-1 text-sm font-medium text-brass group-hover:gap-2 transition-all">
Ansehen Ansehen
<svg <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-4 h-4">
xmlns="http://www.w3.org/2000/svg" <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" />
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> </svg>
</span> </span>
</div> </div>

View file

@ -1,20 +1,21 @@
import Image from 'next/image' import Image from 'next/image'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { HeroBlock as HeroBlockType } from '@/lib/types' import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import type { BlockByType } from '@c2s/payload-contracts/types'
type HeroBlockProps = Omit<HeroBlockType, 'blockType'> type HeroBlockProps = Omit<BlockByType<'hero-block'>, 'blockType' | 'blockName'>
export function HeroBlock({ export function HeroBlock({
heading, headline,
subheading, subline,
backgroundImage, backgroundImage,
ctaText, cta,
ctaLink,
alignment = 'center', alignment = 'center',
overlay = true, overlay = true,
overlayOpacity = 50,
}: HeroBlockProps) { }: HeroBlockProps) {
const bgUrl = getMediaUrl(backgroundImage)
const alignmentClasses = { const alignmentClasses = {
left: 'text-left items-start', left: 'text-left items-start',
center: 'text-center items-center', center: 'text-center items-center',
@ -24,20 +25,17 @@ export function HeroBlock({
return ( return (
<section className="relative min-h-[60vh] md:min-h-[70vh] flex items-center"> <section className="relative min-h-[60vh] md:min-h-[70vh] flex items-center">
{/* Background Image */} {/* Background Image */}
{backgroundImage?.url && ( {bgUrl && (
<div className="absolute inset-0"> <div className="absolute inset-0">
<Image <Image
src={backgroundImage.url} src={bgUrl}
alt={backgroundImage.alt || ''} alt={getMediaAlt(backgroundImage)}
fill fill
className="object-cover" className="object-cover"
priority priority
/> />
{overlay && ( {overlay && (
<div <div className="absolute inset-0 bg-espresso/50" />
className="absolute inset-0 bg-espresso"
style={{ opacity: overlayOpacity / 100 }}
/>
)} )}
</div> </div>
)} )}
@ -47,33 +45,33 @@ export function HeroBlock({
<div <div
className={cn( className={cn(
'flex flex-col max-w-3xl py-20', 'flex flex-col max-w-3xl py-20',
alignmentClasses[alignment], alignmentClasses[alignment || 'center'],
alignment === 'center' && 'mx-auto' (alignment || 'center') === 'center' && 'mx-auto'
)} )}
> >
<h1 <h1
className={cn( className={cn(
'mb-6', 'mb-6',
backgroundImage ? 'text-soft-white' : 'text-espresso' bgUrl ? 'text-soft-white' : 'text-espresso'
)} )}
> >
{heading} {headline}
</h1> </h1>
{subheading && ( {subline && (
<p <p
className={cn( className={cn(
'text-lg md:text-xl leading-relaxed mb-8', 'text-lg md:text-xl leading-relaxed mb-8',
backgroundImage ? 'text-soft-white/90' : 'text-espresso' bgUrl ? 'text-soft-white/90' : 'text-espresso'
)} )}
> >
{subheading} {subline}
</p> </p>
)} )}
{ctaText && ctaLink && ( {cta?.text && cta?.link && (
<Button href={ctaLink} size="lg"> <Button href={cta.link} size="lg">
{ctaText} {cta.text}
</Button> </Button>
)} )}
</div> </div>

View file

@ -1,25 +1,22 @@
import Image from 'next/image' import Image from 'next/image'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { RichTextRenderer } from './RichTextRenderer' import { RichTextRenderer } from './RichTextRenderer'
import type { ImageTextBlock as ImageTextBlockType } from '@/lib/types' import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import type { BlockByType } from '@c2s/payload-contracts/types'
type ImageTextBlockProps = Omit<ImageTextBlockType, 'blockType'> type ImageTextBlockProps = Omit<BlockByType<'image-text-block'>, 'blockType' | 'blockName'>
export function ImageTextBlock({ export function ImageTextBlock({
heading, headline,
content, content,
image, image,
imagePosition = 'left', imagePosition = 'left',
backgroundColor = 'white', cta,
}: ImageTextBlockProps) { }: ImageTextBlockProps) {
const bgClasses = { const imgUrl = getMediaUrl(image)
white: 'bg-soft-white',
ivory: 'bg-ivory',
sand: 'bg-sand/20',
}
return ( return (
<section className={cn('py-16 md:py-20', bgClasses[backgroundColor])}> <section className="py-16 md:py-20">
<div className="container"> <div className="container">
<div <div
className={cn( className={cn(
@ -28,21 +25,31 @@ export function ImageTextBlock({
)} )}
> >
{/* Image */} {/* Image */}
<div className={cn('md:[direction:ltr]')}> <div className="md:[direction:ltr]">
<div className="relative aspect-[4/3] rounded-2xl overflow-hidden"> <div className="relative aspect-[4/3] rounded-2xl overflow-hidden">
<Image {imgUrl && (
src={image.url} <Image
alt={image.alt || ''} src={imgUrl}
fill alt={getMediaAlt(image)}
className="object-cover" fill
/> className="object-cover"
/>
)}
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div className="md:[direction:ltr]"> <div className="md:[direction:ltr]">
{heading && <h2 className="mb-6">{heading}</h2>} {headline && <h2 className="mb-6">{headline}</h2>}
<RichTextRenderer content={content} /> <RichTextRenderer content={content} />
{cta?.text && cta?.link && (
<a
href={cta.link}
className="inline-block mt-6 text-brass font-medium hover:text-brass-hover transition-colors"
>
{cta.text} &rarr;
</a>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -5,38 +5,37 @@ import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { Button, Input } from '@/components/ui' import { Button, Input } from '@/components/ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getMediaUrl } from '@/lib/payload-helpers'
import { subscribeNewsletter } from '@/lib/api' import { subscribeNewsletter } from '@/lib/api'
import type { NewsletterBlock as NewsletterBlockType } from '@/lib/types' import type { BlockByType } from '@c2s/payload-contracts/types'
type NewsletterBlockProps = Omit<NewsletterBlockType, 'blockType'> type NewsletterBlockProps = Omit<BlockByType<'newsletter-block'>, 'blockType' | 'blockName'>
export function NewsletterBlock({ export function NewsletterBlock({
title = 'Newsletter', title = 'Newsletter',
subtitle, subtitle,
buttonText = 'Anmelden', buttonText = 'Anmelden',
layout = 'card', layout = 'card',
backgroundImage, image,
showPrivacyNote = true, collectName = false,
source = 'newsletter-block', source = 'newsletter-block',
showFirstName = false,
}: NewsletterBlockProps) { }: NewsletterBlockProps) {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('') const [firstName, setFirstName] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const bgImageUrl = getMediaUrl(image)
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setStatus('loading') setStatus('loading')
setErrorMessage('') setErrorMessage('')
try { try {
const result = await subscribeNewsletter( const result = await subscribeNewsletter(
email, email,
showFirstName && firstName ? firstName : undefined, collectName && firstName ? firstName : undefined,
source source || 'newsletter-block'
) )
if (result.success) { if (result.success) {
setStatus('success') setStatus('success')
setEmail('') setEmail('')
@ -58,115 +57,44 @@ export function NewsletterBlock({
<p className="text-sm mt-1">Bitte bestätigen Sie Ihre E-Mail-Adresse.</p> <p className="text-sm mt-1">Bitte bestätigen Sie Ihre E-Mail-Adresse.</p>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className={cn( <form onSubmit={handleSubmit} className={cn('flex flex-col gap-4', (layout === 'inline' || layout === 'minimal') && !collectName && 'sm:flex-row')}>
'flex flex-col gap-4', {collectName && (
(layout === 'inline' || layout === 'minimal') && !showFirstName && 'sm:flex-row' <Input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Dein Vorname (optional)" disabled={status === 'loading'} />
)}>
{showFirstName && (
<Input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Dein Vorname (optional)"
disabled={status === 'loading'}
/>
)} )}
<div className={cn( <div className={cn('flex flex-col gap-4', (layout === 'inline' || layout === 'minimal') && 'sm:flex-row')}>
'flex flex-col gap-4', <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')} />
(layout === 'inline' || layout === 'minimal') && 'sm:flex-row' <Button type="submit" disabled={status === 'loading'} className="whitespace-nowrap">
)}> {status === 'loading' ? 'Wird gesendet...' : buttonText || 'Anmelden'}
<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> </Button>
</div> </div>
</form> </form>
)} )}
{status !== 'success' && (
{showPrivacyNote && status !== 'success' && (
<p className="text-sm text-warm-gray-dark mt-4 text-center"> <p className="text-sm text-warm-gray-dark mt-4 text-center">
Mit der Anmeldung akzeptieren Sie unsere{' '} Mit der Anmeldung akzeptieren Sie unsere{' '}
<Link href="/datenschutz" className="underline hover:text-espresso"> <Link href="/datenschutz" className="underline hover:text-espresso">Datenschutzerklärung</Link>.
Datenschutzerklärung
</Link>
.
</p> </p>
)} )}
</> </>
) )
// Minimal layout
if (layout === 'minimal') { if (layout === 'minimal') {
return ( return <section className="py-8"><div className="container max-w-xl">{formContent}</div></section>
<section className="py-8">
<div className="container max-w-xl">
{formContent}
</div>
</section>
)
} }
// Card layout (default)
return ( return (
<section className="py-16 md:py-20"> <section className="py-16 md:py-20">
<div className="container"> <div className="container">
<div <div className={cn('relative bg-soft-white border border-warm-gray rounded-2xl p-8 md:p-10 overflow-hidden', bgImageUrl && 'text-soft-white')}>
className={cn( {bgImageUrl && (
'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"> <div className="absolute inset-0">
<Image <Image src={bgImageUrl} alt="" fill className="object-cover" />
src={backgroundImage.url}
alt=""
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-espresso/60" /> <div className="absolute inset-0 bg-espresso/60" />
</div> </div>
)} )}
<div className="relative z-10 max-w-xl mx-auto text-center"> <div className="relative z-10 max-w-xl mx-auto text-center">
{title && ( {title && <h2 className={cn('mb-3', bgImageUrl && 'text-soft-white')}>{title}</h2>}
<h2 {subtitle && <p className={cn('text-lg mb-6', bgImageUrl ? 'text-soft-white/80' : 'text-espresso/80')}>{subtitle}</p>}
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} {formContent}
</div> </div>
</div> </div>

View file

@ -1,25 +1,19 @@
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { cn, formatDate, getImageUrl } from '@/lib/utils' import { cn, formatDate } from '@/lib/utils'
import { getMediaUrl, getMediaAlt, getAuthorName, resolveRelationArray } from '@/lib/payload-helpers'
import { getPosts } from '@/lib/api' import { getPosts } from '@/lib/api'
import { EmptyState } from '@/components/ui' import { EmptyState } from '@/components/ui'
import type { PostsListBlock as PostsListBlockType, Post, Author } from '@/lib/types' import type { BlockByType, Post, Category } from '@c2s/payload-contracts/types'
// Helper to get author name safely type PostsListBlockProps = Omit<BlockByType<'posts-list-block'>, 'blockType' | 'blockName'>
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({ export async function PostsListBlock({
title, title,
subtitle, subtitle,
postType, postType,
layout = 'grid', layout = 'grid',
columns = 3, columns = '3',
limit = 6, limit = 6,
showFeaturedOnly, showFeaturedOnly,
filterByCategory, filterByCategory,
@ -28,13 +22,18 @@ export async function PostsListBlock({
showAuthor = false, showAuthor = false,
showCategory = true, showCategory = true,
showPagination = false, showPagination = false,
backgroundColor = 'ivory', backgroundColor = 'white',
}: PostsListBlockProps) { }: PostsListBlockProps) {
// filterByCategory is (number | Category)[] in contracts
const categorySlug = filterByCategory
? resolveRelationArray<Category>(filterByCategory)[0]?.slug
: undefined
const postsData = await getPosts({ const postsData = await getPosts({
type: postType, type: postType === 'all' ? undefined : postType,
category: filterByCategory, category: categorySlug,
limit, limit: limit ?? 6,
featured: showFeaturedOnly, featured: showFeaturedOnly ?? undefined,
}) })
const posts = postsData.docs const posts = postsData.docs
@ -46,9 +45,7 @@ export async function PostsListBlock({
{(title || subtitle) && ( {(title || subtitle) && (
<div className="text-center max-w-2xl mx-auto mb-12"> <div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>} {title && <h2 className="mb-4">{title}</h2>}
{subtitle && ( {subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div> </div>
)} )}
<EmptyState <EmptyState
@ -62,68 +59,44 @@ export async function PostsListBlock({
) )
} }
const bgClasses = { const bgClasses: Record<string, string> = {
white: 'bg-soft-white', white: 'bg-soft-white',
ivory: 'bg-ivory', light: 'bg-ivory',
sand: 'bg-sand/20', dark: 'bg-espresso text-soft-white',
} }
const columnClasses = { const columnClasses: Record<string, string> = {
2: 'md:grid-cols-2', '2': 'md:grid-cols-2',
3: 'md:grid-cols-2 lg:grid-cols-3', '3': 'md:grid-cols-2 lg:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-4', '4': 'md:grid-cols-2 lg:grid-cols-4',
} }
return ( return (
<section className={cn('py-16 md:py-20', bgClasses[backgroundColor])}> <section className={cn('py-16 md:py-20', bgClasses[backgroundColor || 'white'])}>
<div className="container"> <div className="container">
{/* Section Header */}
{(title || subtitle) && ( {(title || subtitle) && (
<div className="text-center max-w-2xl mx-auto mb-12"> <div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>} {title && <h2 className="mb-4">{title}</h2>}
{subtitle && ( {subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div> </div>
)} )}
{/* Posts */}
{layout === 'grid' ? ( {layout === 'grid' ? (
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}> <div className={cn('grid grid-cols-1 gap-6', columnClasses[columns || '3'])}>
{posts.map((post) => ( {posts.map((post) => (
<PostCard <PostCard key={post.id} post={post} showExcerpt={showExcerpt} showDate={showDate} showAuthor={showAuthor} showCategory={showCategory} />
key={post.id}
post={post}
showExcerpt={showExcerpt}
showDate={showDate}
showAuthor={showAuthor}
showCategory={showCategory}
/>
))} ))}
</div> </div>
) : layout === 'featured' ? ( ) : layout === 'featured' ? (
<FeaturedLayout <FeaturedLayout posts={posts} showExcerpt={showExcerpt} showDate={showDate} showCategory={showCategory} />
posts={posts}
showExcerpt={showExcerpt}
showDate={showDate}
showCategory={showCategory}
/>
) : layout === 'list' ? ( ) : layout === 'list' ? (
<ListLayout <ListLayout posts={posts} showExcerpt={showExcerpt} showDate={showDate} showCategory={showCategory} />
posts={posts}
showExcerpt={showExcerpt}
showDate={showDate}
showCategory={showCategory}
/>
) : ( ) : (
<CompactLayout posts={posts} showDate={showDate} /> <CompactLayout posts={posts} showDate={showDate} />
)} )}
{/* Pagination would go here */}
{showPagination && postsData.totalPages > 1 && ( {showPagination && postsData.totalPages > 1 && (
<div className="mt-12 text-center"> <div className="mt-12 text-center" />
{/* Implement pagination component */}
</div>
)} )}
</div> </div>
</section> </section>
@ -132,21 +105,17 @@ export async function PostsListBlock({
interface PostCardProps { interface PostCardProps {
post: Post post: Post
showExcerpt?: boolean showExcerpt?: boolean | null
showDate?: boolean showDate?: boolean | null
showAuthor?: boolean showAuthor?: boolean | null
showCategory?: boolean showCategory?: boolean | null
} }
function PostCard({ function PostCard({ post, showExcerpt, showDate, showAuthor, showCategory }: PostCardProps) {
post, const imageUrl = getMediaUrl(post.featuredImage)
showExcerpt,
showDate,
showAuthor,
showCategory,
}: PostCardProps) {
const imageUrl = getImageUrl(post.featuredImage)
const postUrl = getPostUrl(post) const postUrl = getPostUrl(post)
const categories = resolveRelationArray<Category>(post.categories)
const authorName = getAuthorName(post.author)
return ( return (
<Link href={postUrl} className="group block"> <Link href={postUrl} className="group block">
@ -155,205 +124,114 @@ function PostCard({
<div className="relative aspect-video overflow-hidden"> <div className="relative aspect-video overflow-hidden">
<Image <Image
src={imageUrl} src={imageUrl}
alt={post.featuredImage?.alt || post.title} alt={getMediaAlt(post.featuredImage, post.title)}
fill fill
className="object-cover transition-transform duration-300 group-hover:scale-105" className="object-cover transition-transform duration-300 group-hover:scale-105"
/> />
</div> </div>
)} )}
<div className="p-6"> <div className="p-6">
{/* Meta */}
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-3"> <div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-3">
{showCategory && post.categories?.[0] && ( {showCategory && categories[0] && (
<span className="text-brass font-medium"> <span className="text-brass font-medium">{categories[0].name}</span>
{post.categories[0].title}
</span>
)}
{showDate && post.publishedAt && (
<span>{formatDate(post.publishedAt)}</span>
)} )}
{showDate && post.publishedAt && <span>{formatDate(post.publishedAt)}</span>}
</div> </div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-brass transition-colors">{post.title}</h3>
{/* Title */} {showExcerpt && post.excerpt && <p className="text-espresso/80 line-clamp-2">{post.excerpt}</p>}
<h3 className="text-xl font-semibold mb-2 group-hover:text-brass transition-colors"> {showAuthor && authorName && <p className="mt-4 text-sm text-warm-gray-dark">von {authorName}</p>}
{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> </div>
</article> </article>
</Link> </Link>
) )
} }
function FeaturedLayout({ function FeaturedLayout({ posts, showExcerpt, showDate, showCategory }: { posts: Post[]; showExcerpt?: boolean | null; showDate?: boolean | null; showCategory?: boolean | null }) {
posts,
showExcerpt,
showDate,
showCategory,
}: {
posts: Post[]
showExcerpt?: boolean
showDate?: boolean
showCategory?: boolean
}) {
const [featured, ...rest] = posts const [featured, ...rest] = posts
const imageUrl = getMediaUrl(featured.featuredImage)
const categories = resolveRelationArray<Category>(featured.categories)
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <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"> <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"> <article className="h-full bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-xl">
{featured.featuredImage && ( {imageUrl && (
<div className="relative aspect-[4/3] lg:aspect-[16/10] overflow-hidden"> <div className="relative aspect-[4/3] lg:aspect-[16/10] overflow-hidden">
<Image <Image src={imageUrl} alt={getMediaAlt(featured.featuredImage, featured.title)} fill className="object-cover transition-transform duration-300 group-hover:scale-105" />
src={featured.featuredImage.url}
alt={featured.featuredImage.alt || featured.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div> </div>
)} )}
<div className="p-6 lg:p-8"> <div className="p-6 lg:p-8">
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-3"> <div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-3">
{showCategory && featured.categories?.[0] && ( {showCategory && categories[0] && <span className="text-brass font-medium">{categories[0].name}</span>}
<span className="text-brass font-medium"> {showDate && featured.publishedAt && <span>{formatDate(featured.publishedAt)}</span>}
{featured.categories[0].title}
</span>
)}
{showDate && featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div> </div>
<h3 className="text-2xl lg:text-3xl font-semibold mb-3 group-hover:text-brass transition-colors"> <h3 className="text-2xl lg:text-3xl font-semibold mb-3 group-hover:text-brass transition-colors">{featured.title}</h3>
{featured.title} {showExcerpt && featured.excerpt && <p className="text-espresso/80 line-clamp-3">{featured.excerpt}</p>}
</h3>
{showExcerpt && featured.excerpt && (
<p className="text-espresso/80 line-clamp-3">{featured.excerpt}</p>
)}
</div> </div>
</article> </article>
</Link> </Link>
{/* Secondary Posts */}
<div className="space-y-6"> <div className="space-y-6">
{rest.slice(0, 2).map((post) => ( {rest.slice(0, 2).map((post) => {
<Link key={post.id} href={getPostUrl(post)} className="group block"> const img = getMediaUrl(post.featuredImage)
<article className="flex gap-4 bg-soft-white border border-warm-gray rounded-xl p-4 transition-all duration-300 hover:shadow-lg"> return (
{post.featuredImage && ( <Link key={post.id} href={getPostUrl(post)} className="group block">
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden"> <article className="flex gap-4 bg-soft-white border border-warm-gray rounded-xl p-4 transition-all duration-300 hover:shadow-lg">
<Image {img && (
src={post.featuredImage.url} <div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden">
alt={post.featuredImage.alt || post.title} <Image src={img} alt={getMediaAlt(post.featuredImage, post.title)} fill className="object-cover" />
fill </div>
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"> <div className="flex-1 min-w-0">
{post.title} {showDate && post.publishedAt && <span className="text-sm text-warm-gray-dark">{formatDate(post.publishedAt)}</span>}
</h4> <h4 className="font-semibold line-clamp-2 group-hover:text-brass transition-colors">{post.title}</h4>
</div> </div>
</article> </article>
</Link> </Link>
))} )
})}
</div> </div>
</div> </div>
) )
} }
function ListLayout({ function ListLayout({ posts, showExcerpt, showDate, showCategory }: { posts: Post[]; showExcerpt?: boolean | null; showDate?: boolean | null; showCategory?: boolean | null }) {
posts,
showExcerpt,
showDate,
showCategory,
}: {
posts: Post[]
showExcerpt?: boolean
showDate?: boolean
showCategory?: boolean
}) {
return ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
{posts.map((post) => ( {posts.map((post) => {
<Link key={post.id} href={getPostUrl(post)} className="group block"> const img = getMediaUrl(post.featuredImage)
<article className="flex gap-6 bg-soft-white border border-warm-gray rounded-xl p-6 transition-all duration-300 hover:shadow-lg"> const categories = resolveRelationArray<Category>(post.categories)
{post.featuredImage && ( return (
<div className="relative w-32 h-32 md:w-48 md:h-32 flex-shrink-0 rounded-lg overflow-hidden"> <Link key={post.id} href={getPostUrl(post)} className="group block">
<Image <article className="flex gap-6 bg-soft-white border border-warm-gray rounded-xl p-6 transition-all duration-300 hover:shadow-lg">
src={post.featuredImage.url} {img && (
alt={post.featuredImage.alt || post.title} <div className="relative w-32 h-32 md:w-48 md:h-32 flex-shrink-0 rounded-lg overflow-hidden">
fill <Image src={img} alt={getMediaAlt(post.featuredImage, post.title)} fill className="object-cover" />
className="object-cover" </div>
/>
</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> <div className="flex-1 min-w-0">
</article> <div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-2">
</Link> {showCategory && categories[0] && <span className="text-brass font-medium">{categories[0].name}</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> </div>
) )
} }
function CompactLayout({ function CompactLayout({ posts, showDate }: { posts: Post[]; showDate?: boolean | null }) {
posts,
showDate,
}: {
posts: Post[]
showDate?: boolean
}) {
return ( return (
<div className="max-w-2xl mx-auto divide-y divide-warm-gray"> <div className="max-w-2xl mx-auto divide-y divide-warm-gray">
{posts.map((post) => ( {posts.map((post) => (
<Link <Link key={post.id} href={getPostUrl(post)} className="group block py-4 first:pt-0 last:pb-0">
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"> <article className="flex items-center justify-between gap-4">
<h4 className="font-medium group-hover:text-brass transition-colors"> <h4 className="font-medium group-hover:text-brass transition-colors">{post.title}</h4>
{post.title} {showDate && post.publishedAt && <span className="text-sm text-warm-gray-dark whitespace-nowrap">{formatDate(post.publishedAt)}</span>}
</h4>
{showDate && post.publishedAt && (
<span className="text-sm text-warm-gray-dark whitespace-nowrap">
{formatDate(post.publishedAt)}
</span>
)}
</article> </article>
</Link> </Link>
))} ))}
@ -368,6 +246,5 @@ function getPostUrl(post: Post): string {
press: '/presse', press: '/presse',
announcement: '/aktuelles', announcement: '/aktuelles',
} }
const prefix = prefixes[post.type] || '/blog' return `${prefixes[post.type] || '/blog'}/${post.slug}`
return `${prefix}/${post.slug}`
} }

View file

@ -1,47 +1,35 @@
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { cn, getImageUrl } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import { SeriesPill } from '@/components/ui' import { SeriesPill } from '@/components/ui'
import { getSeries } from '@/lib/api' import { getSeries } from '@/lib/api'
import { RichTextRenderer } from './RichTextRenderer' import { RichTextRenderer } from './RichTextRenderer'
import type { SeriesBlock as SeriesBlockType, Series } from '@/lib/types' import type { BlockByType, Series } from '@c2s/payload-contracts/types'
type SeriesBlockProps = Omit<SeriesBlockType, 'blockType'> type SeriesBlockProps = Omit<BlockByType<'series-block'>, 'blockType' | 'blockName'>
export async function SeriesBlock({ export async function SeriesBlock({
title, title,
subtitle, subtitle,
displayMode,
selectedSeries,
layout = 'grid', layout = 'grid',
showDescription = true, showDescription = true,
limit,
}: SeriesBlockProps) { }: SeriesBlockProps) {
// Fetch series if not using selected mode const seriesData = await getSeries({ limit: limit ?? 20 })
let items: Series[] = [] const items = seriesData.docs
if (displayMode === 'selected' && selectedSeries) {
items = selectedSeries
} else {
const seriesData = await getSeries()
items = seriesData.docs
}
if (!items || items.length === 0) return null if (!items || items.length === 0) return null
return ( return (
<section className="py-16 md:py-20"> <section className="py-16 md:py-20">
<div className="container"> <div className="container">
{/* Section Header */}
{(title || subtitle) && ( {(title || subtitle) && (
<div className="text-center max-w-2xl mx-auto mb-12"> <div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>} {title && <h2 className="mb-4">{title}</h2>}
{subtitle && ( {subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div> </div>
)} )}
{/* Series */}
{layout === 'featured' ? ( {layout === 'featured' ? (
<FeaturedLayout items={items} showDescription={showDescription} /> <FeaturedLayout items={items} showDescription={showDescription} />
) : layout === 'list' ? ( ) : layout === 'list' ? (
@ -54,223 +42,89 @@ export async function SeriesBlock({
) )
} }
function GridLayout({ function GridLayout({ items, showDescription }: { items: Series[]; showDescription?: boolean | null }) {
items,
showDescription,
}: {
items: Series[]
showDescription?: boolean
}) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{items.map((series) => ( {items.map((series) => <SeriesCard key={series.id} series={series} showDescription={showDescription} />)}
<SeriesCard
key={series.id}
series={series}
showDescription={showDescription}
/>
))}
</div> </div>
) )
} }
function ListLayout({ function ListLayout({ items, showDescription }: { items: Series[]; showDescription?: boolean | null }) {
items,
showDescription,
}: {
items: Series[]
showDescription?: boolean
}) {
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{items.map((series) => ( {items.map((series) => {
<Link const logoUrl = getMediaUrl(series.logo)
key={series.id} const coverUrl = getMediaUrl(series.coverImage)
href={`/serien/${series.slug}`} return (
className="group block" <Link key={series.id} href={`/serien/${series.slug}`} 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">
<article className="flex gap-6 bg-soft-white border border-warm-gray rounded-xl p-6 transition-all duration-300 hover:shadow-lg"> <div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-ivory flex items-center justify-center">
{/* Logo/Image */} {logoUrl ? (
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-ivory flex items-center justify-center"> <Image src={logoUrl} alt={series.title} fill className="object-contain p-2" />
{series.logo ? ( ) : coverUrl ? (
<Image <Image src={coverUrl} alt={series.title} fill className="object-cover" />
src={series.logo.url} ) : (
alt={series.title} <SeriesPill series={series.slug} size="lg">{series.title.slice(0, 2).toUpperCase()}</SeriesPill>
fill )}
className="object-contain p-2" </div>
/> <div className="flex-1 min-w-0">
) : series.coverImage ? ( <h3 className="text-xl font-semibold mb-2 group-hover:text-brass transition-colors">{series.title}</h3>
<Image {showDescription && series.description && <div className="text-espresso/80 line-clamp-2"><RichTextRenderer content={series.description} /></div>}
src={series.coverImage.url} </div>
alt={series.title} </article>
fill </Link>
className="object-cover" )
/> })}
) : (
<SeriesPill series={series.slug} size="lg">
{series.title.slice(0, 2).toUpperCase()}
</SeriesPill>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold mb-2 group-hover:text-brass transition-colors">
{series.title}
</h3>
{showDescription && series.description && (
<div className="text-espresso/80 line-clamp-2">
<RichTextRenderer content={series.description} />
</div>
)}
</div>
</article>
</Link>
))}
</div> </div>
) )
} }
function FeaturedLayout({ function FeaturedLayout({ items, showDescription }: { items: Series[]; showDescription?: boolean | null }) {
items,
showDescription,
}: {
items: Series[]
showDescription?: boolean
}) {
const [featured, ...rest] = items const [featured, ...rest] = items
const coverUrl = getMediaUrl(featured.coverImage)
const logoUrl = getMediaUrl(featured.logo)
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Featured Series */} <Link href={`/serien/${featured.slug}`} className="group block lg:row-span-2">
<Link <article className="relative h-full min-h-[400px] rounded-2xl overflow-hidden" style={{ backgroundColor: featured.brandColor || '#C6A47E' }}>
href={`/serien/${featured.slug}`} {coverUrl && <Image src={coverUrl} alt={featured.title} fill className="object-cover opacity-30" />}
className="group block lg:row-span-2"
>
<article
className="relative h-full min-h-[400px] rounded-2xl overflow-hidden"
style={{
backgroundColor: featured.brandColor || '#C6A47E',
}}
>
{featured.coverImage && (
<Image
src={featured.coverImage.url}
alt={featured.title}
fill
className="object-cover opacity-30"
/>
)}
<div className="absolute inset-0 flex flex-col justify-end p-8"> <div className="absolute inset-0 flex flex-col justify-end p-8">
{featured.logo && ( {logoUrl && <div className="relative w-32 h-16 mb-4"><Image src={logoUrl} alt="" fill className="object-contain object-left" /></div>}
<div className="relative w-32 h-16 mb-4"> <h3 className="text-2xl lg:text-3xl font-semibold text-soft-white mb-3">{featured.title}</h3>
<Image {showDescription && featured.description && <div className="text-soft-white/80 line-clamp-3"><RichTextRenderer content={featured.description} /></div>}
src={featured.logo.url}
alt=""
fill
className="object-contain object-left"
/>
</div>
)}
<h3 className="text-2xl lg:text-3xl font-semibold text-soft-white mb-3">
{featured.title}
</h3>
{showDescription && featured.description && (
<div className="text-soft-white/80 line-clamp-3">
<RichTextRenderer content={featured.description} />
</div>
)}
</div> </div>
</article> </article>
</Link> </Link>
{/* Other Series */}
<div className="space-y-6"> <div className="space-y-6">
{rest.slice(0, 3).map((series) => ( {rest.slice(0, 3).map((series) => <SeriesCard key={series.id} series={series} showDescription={false} compact />)}
<SeriesCard
key={series.id}
series={series}
showDescription={false}
compact
/>
))}
</div> </div>
</div> </div>
) )
} }
interface SeriesCardProps { function SeriesCard({ series, showDescription, compact }: { series: Series; showDescription?: boolean | null; compact?: boolean }) {
series: Series const coverUrl = getMediaUrl(series.coverImage)
showDescription?: boolean const logoUrl = getMediaUrl(series.logo)
compact?: boolean const imageUrl = coverUrl || logoUrl
}
function SeriesCard({ series, showDescription, compact }: SeriesCardProps) {
const imageUrl = getImageUrl(series.coverImage) || getImageUrl(series.logo)
return ( return (
<Link href={`/serien/${series.slug}`} className="group block"> <Link href={`/serien/${series.slug}`} className="group block">
<article <article className={cn('bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl', compact && 'flex items-center gap-4 p-4')}>
className={cn(
'bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl',
compact && 'flex items-center gap-4 p-4'
)}
>
{/* Image */}
{!compact && ( {!compact && (
<div <div className="relative aspect-video" style={{ backgroundColor: series.brandColor || '#C6A47E' }}>
className="relative aspect-video" {imageUrl && <Image src={imageUrl} alt={series.title} fill className={cn(logoUrl && !coverUrl ? 'object-contain p-8' : 'object-cover opacity-50')} />}
style={{ backgroundColor: series.brandColor || '#C6A47E' }}
>
{imageUrl && (
<Image
src={imageUrl}
alt={series.title}
fill
className={cn(
series.logo ? 'object-contain p-8' : 'object-cover opacity-50'
)}
/>
)}
</div> </div>
)} )}
{compact && ( {compact && (
<div <div className="relative w-16 h-16 flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center" style={{ backgroundColor: series.brandColor || '#C6A47E' }}>
className="relative w-16 h-16 flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center" {logoUrl ? <Image src={logoUrl} alt="" fill className="object-contain p-2" /> : <span className="text-soft-white font-bold text-lg">{series.title.slice(0, 2).toUpperCase()}</span>}
style={{ backgroundColor: series.brandColor || '#C6A47E' }}
>
{series.logo ? (
<Image
src={series.logo.url}
alt=""
fill
className="object-contain p-2"
/>
) : (
<span className="text-soft-white font-bold text-lg">
{series.title.slice(0, 2).toUpperCase()}
</span>
)}
</div> </div>
)} )}
{/* Content */}
<div className={cn(!compact && 'p-6', compact && 'flex-1 min-w-0')}> <div className={cn(!compact && 'p-6', compact && 'flex-1 min-w-0')}>
<h3 <h3 className={cn('font-semibold group-hover:text-brass transition-colors', compact ? 'text-lg' : 'text-xl mb-2')}>{series.title}</h3>
className={cn( {showDescription && !compact && series.description && <div className="text-espresso/80 line-clamp-2"><RichTextRenderer content={series.description} /></div>}
'font-semibold group-hover:text-brass transition-colors',
compact ? 'text-lg' : 'text-xl mb-2'
)}
>
{series.title}
</h3>
{showDescription && !compact && series.description && (
<div className="text-espresso/80 line-clamp-2">
<RichTextRenderer content={series.description} />
</div>
)}
</div> </div>
</article> </article>
</Link> </Link>

View file

@ -1,56 +1,45 @@
import Image from 'next/image' import Image from 'next/image'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { resolveRelation, getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import { RichTextRenderer } from './RichTextRenderer' import { RichTextRenderer } from './RichTextRenderer'
import type { SeriesDetailBlock as SeriesDetailBlockType } from '@/lib/types' import type { BlockByType, Series } from '@c2s/payload-contracts/types'
type SeriesDetailBlockProps = Omit<SeriesDetailBlockType, 'blockType'> type SeriesDetailBlockProps = Omit<BlockByType<'series-detail-block'>, 'blockType' | 'blockName'>
export function SeriesDetailBlock({ export function SeriesDetailBlock({
series, series: seriesRef,
layout = 'hero', layout = 'full',
showLogo = true, showBrandColors = true,
showPlaylistLink = true, showYoutubePlaylist = true,
useBrandColor = true, showHero = true,
showDescription = true,
}: SeriesDetailBlockProps) { }: SeriesDetailBlockProps) {
const bgColor = useBrandColor && series.brandColor ? series.brandColor : '#2B2520' const series = resolveRelation<Series>(seriesRef)
if (!series) return null
const bgColor = showBrandColors && series.brandColor ? series.brandColor : '#2B2520'
const coverUrl = getMediaUrl(series.coverImage)
const logoUrl = getMediaUrl(series.logo)
const playlistUrl = series.youtubePlaylistId const playlistUrl = series.youtubePlaylistId
? `https://www.youtube.com/playlist?list=${series.youtubePlaylistId}` ? `https://www.youtube.com/playlist?list=${series.youtubePlaylistId}`
: null : series.youtubePlaylistUrl || null
if (layout === 'compact') { if (layout === 'compact') {
return ( return (
<section <section className="py-12" style={{ backgroundColor: showBrandColors ? bgColor : undefined }}>
className="py-12"
style={{ backgroundColor: useBrandColor ? bgColor : undefined }}
>
<div className="container"> <div className="container">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{showLogo && series.logo && ( {logoUrl && (
<div className="relative w-24 h-16 flex-shrink-0"> <div className="relative w-24 h-16 flex-shrink-0">
<Image <Image src={logoUrl} alt={getMediaAlt(series.logo, series.title)} fill className="object-contain" />
src={series.logo.url}
alt={series.title}
fill
className="object-contain"
/>
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
<h1 className={cn(useBrandColor && 'text-soft-white')}> <h1 className={cn(showBrandColors && 'text-soft-white')}>{series.title}</h1>
{series.title}
</h1>
</div> </div>
{showPlaylistLink && playlistUrl && ( {showYoutubePlaylist && playlistUrl && (
<Button <Button href={playlistUrl} external variant="secondary" className={cn(showBrandColors && 'border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso')}>
href={playlistUrl}
external
variant="secondary"
className={cn(
useBrandColor &&
'border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso'
)}
>
Playlist ansehen Playlist ansehen
</Button> </Button>
)} )}
@ -60,63 +49,22 @@ export function SeriesDetailBlock({
) )
} }
// Hero layout (default) // Full layout (default)
return ( return (
<section className="relative min-h-[50vh] md:min-h-[60vh] flex items-center"> <section className="relative min-h-[50vh] md:min-h-[60vh] flex items-center">
{/* Background */} <div className="absolute inset-0" style={{ backgroundColor: bgColor }}>
<div {showHero && coverUrl && <Image src={coverUrl} alt="" fill className="object-cover opacity-30" />}
className="absolute inset-0"
style={{ backgroundColor: bgColor }}
>
{series.coverImage && (
<Image
src={series.coverImage.url}
alt=""
fill
className="object-cover opacity-30"
/>
)}
</div> </div>
{/* Content */}
<div className="container relative z-10 py-16"> <div className="container relative z-10 py-16">
<div className="max-w-3xl"> <div className="max-w-3xl">
{/* Logo */} {logoUrl && <div className="relative w-48 h-20 mb-6"><Image src={logoUrl} alt="" fill className="object-contain object-left" /></div>}
{showLogo && series.logo && (
<div className="relative w-48 h-20 mb-6">
<Image
src={series.logo.url}
alt=""
fill
className="object-contain object-left"
/>
</div>
)}
{/* Title */}
<h1 className="text-soft-white mb-6">{series.title}</h1> <h1 className="text-soft-white mb-6">{series.title}</h1>
{showDescription && series.description && (
{/* Description */} <div className="text-soft-white/80 text-lg mb-8"><RichTextRenderer content={series.description} /></div>
{series.description && (
<div className="text-soft-white/80 text-lg mb-8">
<RichTextRenderer content={series.description} />
</div>
)} )}
{showYoutubePlaylist && playlistUrl && (
{/* CTA */} <Button href={playlistUrl} external size="lg" className="bg-soft-white text-espresso hover:bg-ivory">
{showPlaylistLink && playlistUrl && ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
<Button
href={playlistUrl}
external
size="lg"
className="bg-soft-white text-espresso hover:bg-ivory"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z" /> <path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z" />
<path fill="white" d="M9.545 15.568V8.432L15.818 12l-6.273 3.568z" /> <path fill="white" d="M9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg> </svg>

View file

@ -2,15 +2,15 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { StatsBlock as StatsBlockType } from '@/lib/types' import type { BlockByType } from '@c2s/payload-contracts/types'
type StatsBlockProps = Omit<StatsBlockType, 'blockType'> type StatsBlockProps = Omit<BlockByType<'stats-block'>, 'blockType' | 'blockName'>
export function StatsBlock({ export function StatsBlock({
title, title,
subtitle, subtitle,
stats, stats,
backgroundColor = 'soft-white', style,
layout = 'row', layout = 'row',
}: StatsBlockProps) { }: StatsBlockProps) {
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
@ -26,117 +26,38 @@ export function StatsBlock({
}, },
{ threshold: 0.2 } { threshold: 0.2 }
) )
if (sectionRef.current) observer.observe(sectionRef.current)
if (sectionRef.current) {
observer.observe(sectionRef.current)
}
return () => observer.disconnect() return () => observer.disconnect()
}, []) }, [])
const bgClasses = { const bgClasses: Record<string, string> = {
ivory: 'bg-ivory', none: 'bg-soft-white',
'soft-white': 'bg-soft-white', light: 'bg-ivory',
sand: 'bg-sand/20', dark: 'bg-espresso text-soft-white',
primary: 'bg-brass text-soft-white',
gradient: 'bg-gradient-to-br from-espresso to-brass text-soft-white',
} }
return ( return (
<section <section ref={sectionRef} className={cn('py-16 md:py-24', bgClasses[style?.bg || 'none'])}>
ref={sectionRef}
className={cn('py-16 md:py-24', bgClasses[backgroundColor])}
>
<div className="container"> <div className="container">
{/* Header */}
{(title || subtitle) && ( {(title || subtitle) && (
<div className="text-center mb-12 md:mb-16"> <div className="text-center mb-12 md:mb-16">
{title && ( {title && <h2 className={cn('mb-3 transition-all duration-700 ease-out', isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4')}>{title}</h2>}
<h2 {subtitle && <p className={cn('text-lg opacity-70 max-w-2xl mx-auto transition-all duration-700 ease-out delay-100', isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4')}>{subtitle}</p>}
className={cn(
'mb-3 transition-all duration-700 ease-out',
isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-4'
)}
>
{title}
</h2>
)}
{subtitle && (
<p
className={cn(
'text-lg text-espresso/70 max-w-2xl mx-auto transition-all duration-700 ease-out delay-100',
isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-4'
)}
>
{subtitle}
</p>
)}
</div> </div>
)} )}
<div className={cn('grid gap-8 md:gap-12', layout === 'row' ? 'grid-cols-1 md:grid-cols-3' : 'grid-cols-2 md:grid-cols-4')}>
{/* Stats Grid */}
<div
className={cn(
'grid gap-8 md:gap-12',
layout === 'row'
? 'grid-cols-1 md:grid-cols-3'
: 'grid-cols-2 md:grid-cols-4'
)}
>
{stats?.map((stat, index) => ( {stats?.map((stat, index) => (
<div <div key={stat.id || index} className={cn('text-center group transition-all duration-700 ease-out', isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8')} style={{ transitionDelay: isVisible ? `${200 + index * 100}ms` : '0ms' }}>
key={index}
className={cn(
'text-center group transition-all duration-700 ease-out',
isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-8'
)}
style={{
transitionDelay: isVisible ? `${200 + index * 100}ms` : '0ms',
}}
>
{/* Value - Large expressive number */}
<div className="relative mb-4"> <div className="relative mb-4">
<span <span className={cn('block font-headline text-5xl md:text-6xl lg:text-7xl font-semibold', style?.bg === 'dark' || style?.bg === 'primary' || style?.bg === 'gradient' ? 'text-soft-white' : 'text-brass', 'transition-transform duration-500 ease-out group-hover:scale-105')}>
className={cn( {stat.prefix}{stat.value ?? stat.label}{stat.suffix}
'block font-headline text-5xl md:text-6xl lg:text-7xl',
'font-semibold text-brass',
'transition-transform duration-500 ease-out',
'group-hover:scale-105'
)}
>
{stat.value}
</span> </span>
{/* Decorative underline */} <div className={cn('absolute -bottom-2 left-1/2 -translate-x-1/2 h-0.5 bg-gradient-to-r from-transparent via-brass/40 to-transparent transition-all duration-500 ease-out', isVisible ? 'w-16 opacity-100' : 'w-0 opacity-0')} style={{ transitionDelay: isVisible ? `${400 + index * 100}ms` : '0ms' }} />
<div
className={cn(
'absolute -bottom-2 left-1/2 -translate-x-1/2',
'h-0.5 bg-gradient-to-r from-transparent via-brass/40 to-transparent',
'transition-all duration-500 ease-out',
isVisible ? 'w-16 opacity-100' : 'w-0 opacity-0'
)}
style={{
transitionDelay: isVisible
? `${400 + index * 100}ms`
: '0ms',
}}
/>
</div> </div>
<h3 className="text-lg md:text-xl font-medium mb-2">{stat.label}</h3>
{/* Label */} {stat.description && <p className="text-sm opacity-60 max-w-xs mx-auto leading-relaxed">{stat.description}</p>}
<h3 className="text-lg md:text-xl font-medium text-espresso mb-2">
{stat.label}
</h3>
{/* Description */}
{stat.description && (
<p className="text-sm text-espresso/60 max-w-xs mx-auto leading-relaxed">
{stat.description}
</p>
)}
</div> </div>
))} ))}
</div> </div>

View file

@ -3,9 +3,10 @@
import { useState } from 'react' import { useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TestimonialsBlock as TestimonialsBlockType, Testimonial } from '@/lib/types' import { resolveRelationArray, getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
import type { BlockByType, Testimonial } from '@c2s/payload-contracts/types'
type TestimonialsBlockProps = Omit<TestimonialsBlockType, 'blockType'> & { type TestimonialsBlockProps = Omit<BlockByType<'testimonials-block'>, 'blockType' | 'blockName'> & {
testimonials?: Testimonial[] testimonials?: Testimonial[]
} }
@ -14,28 +15,25 @@ export function TestimonialsBlock({
subtitle, subtitle,
displayMode, displayMode,
selectedTestimonials, selectedTestimonials,
layout = 'carousel', layout = 'slider',
testimonials: externalTestimonials, testimonials: externalTestimonials,
}: TestimonialsBlockProps) { }: TestimonialsBlockProps) {
const items = displayMode === 'selected' ? selectedTestimonials : externalTestimonials const items = displayMode === 'selected'
? resolveRelationArray<Testimonial>(selectedTestimonials)
: externalTestimonials || []
if (!items || items.length === 0) return null if (!items || items.length === 0) return null
return ( return (
<section className="py-16 md:py-20 bg-soft-white"> <section className="py-16 md:py-20 bg-soft-white">
<div className="container"> <div className="container">
{/* Section Header */}
{(title || subtitle) && ( {(title || subtitle) && (
<div className="text-center max-w-2xl mx-auto mb-12"> <div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>} {title && <h2 className="mb-4">{title}</h2>}
{subtitle && ( {subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div> </div>
)} )}
{layout === 'slider' ? (
{/* Testimonials */}
{layout === 'carousel' ? (
<CarouselLayout items={items} /> <CarouselLayout items={items} />
) : layout === 'grid' ? ( ) : layout === 'grid' ? (
<GridLayout items={items} /> <GridLayout items={items} />
@ -49,7 +47,6 @@ export function TestimonialsBlock({
function CarouselLayout({ items }: { items: Testimonial[] }) { function CarouselLayout({ items }: { items: Testimonial[] }) {
const [current, setCurrent] = useState(0) const [current, setCurrent] = useState(0)
const prev = () => setCurrent((i) => (i === 0 ? items.length - 1 : i - 1)) const prev = () => setCurrent((i) => (i === 0 ? items.length - 1 : i - 1))
const next = () => setCurrent((i) => (i === items.length - 1 ? 0 : i + 1)) const next = () => setCurrent((i) => (i === items.length - 1 ? 0 : i + 1))
@ -57,70 +54,23 @@ function CarouselLayout({ items }: { items: Testimonial[] }) {
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<div className="relative"> <div className="relative">
{items.map((testimonial, index) => ( {items.map((testimonial, index) => (
<div <div key={testimonial.id} className={cn('transition-opacity duration-500', index === current ? 'opacity-100' : 'opacity-0 absolute inset-0')}>
key={testimonial.id}
className={cn(
'transition-opacity duration-500',
index === current ? 'opacity-100' : 'opacity-0 absolute inset-0'
)}
>
<TestimonialCard testimonial={testimonial} variant="featured" /> <TestimonialCard testimonial={testimonial} variant="featured" />
</div> </div>
))} ))}
</div> </div>
{/* Navigation */}
{items.length > 1 && ( {items.length > 1 && (
<div className="flex justify-center items-center gap-4 mt-8"> <div className="flex justify-center items-center gap-4 mt-8">
<button <button type="button" onClick={prev} className="p-2 rounded-full border border-warm-gray hover:border-brass hover:text-brass transition-colors" aria-label="Vorheriges Testimonial">
type="button" <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>
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> </button>
<div className="flex gap-2"> <div className="flex gap-2">
{items.map((_, index) => ( {items.map((_, index) => (
<button <button key={index} type="button" onClick={() => setCurrent(index)} className={cn('w-2 h-2 rounded-full transition-colors', index === current ? 'bg-brass' : 'bg-warm-gray')} aria-label={`Testimonial ${index + 1}`} />
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> </div>
<button type="button" onClick={next} className="p-2 rounded-full border border-warm-gray hover:border-brass hover:text-brass transition-colors" aria-label="Naechstes Testimonial">
<button <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>
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> </button>
</div> </div>
)} )}
@ -131,9 +81,7 @@ function CarouselLayout({ items }: { items: Testimonial[] }) {
function GridLayout({ items }: { items: Testimonial[] }) { function GridLayout({ items }: { items: Testimonial[] }) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{items.map((testimonial) => ( {items.map((t) => <TestimonialCard key={t.id} testimonial={t} />)}
<TestimonialCard key={testimonial.id} testimonial={testimonial} />
))}
</div> </div>
) )
} }
@ -141,85 +89,37 @@ function GridLayout({ items }: { items: Testimonial[] }) {
function ListLayout({ items }: { items: Testimonial[] }) { function ListLayout({ items }: { items: Testimonial[] }) {
return ( return (
<div className="max-w-3xl mx-auto space-y-8"> <div className="max-w-3xl mx-auto space-y-8">
{items.map((testimonial) => ( {items.map((t) => <TestimonialCard key={t.id} testimonial={t} variant="wide" />)}
<TestimonialCard key={testimonial.id} testimonial={testimonial} variant="wide" />
))}
</div> </div>
) )
} }
interface TestimonialCardProps { function TestimonialCard({ testimonial, variant = 'default' }: { testimonial: Testimonial; variant?: 'default' | 'featured' | 'wide' }) {
testimonial: Testimonial const avatarUrl = getMediaUrl(testimonial.image)
variant?: 'default' | 'featured' | 'wide'
}
function TestimonialCard({ testimonial, variant = 'default' }: TestimonialCardProps) {
return ( return (
<div <div className={cn('bg-ivory border-l-4 border-brass rounded-lg p-8', variant === 'featured' && 'text-center border-l-0 border-t-4')}>
className={cn( <blockquote className={cn('font-headline text-xl font-medium italic text-espresso leading-relaxed mb-6', variant === 'featured' && 'text-2xl')}>
'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; &ldquo;{testimonial.quote}&rdquo;
</blockquote> </blockquote>
<div className={cn('flex items-center gap-4', variant === 'featured' && 'justify-center')}>
{/* Author */} {avatarUrl && (
<div <Image src={avatarUrl} alt={getMediaAlt(testimonial.image, testimonial.author)} width={48} height={48} className="w-12 h-12 rounded-full object-cover" />
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> <div>
<p className="font-semibold text-espresso">{testimonial.authorName}</p> <p className="font-semibold text-espresso">{testimonial.author}</p>
{(testimonial.authorTitle || testimonial.authorCompany) && ( {(testimonial.role || testimonial.company) && (
<p className="text-sm text-warm-gray-dark"> <p className="text-sm text-warm-gray-dark">
{[testimonial.authorTitle, testimonial.authorCompany] {[testimonial.role, testimonial.company].filter(Boolean).join(', ')}
.filter(Boolean)
.join(', ')}
</p> </p>
)} )}
</div> </div>
</div> </div>
{/* Rating */}
{testimonial.rating && ( {testimonial.rating && (
<div className={cn('flex gap-1 mt-4', variant === 'featured' && 'justify-center')}> <div className={cn('flex gap-1 mt-4', variant === 'featured' && 'justify-center')}>
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<svg <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')}>
key={i} <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" />
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> </svg>
))} ))}
</div> </div>

View file

@ -1,31 +1,23 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TextBlock as TextBlockType } from '@/lib/types'
import { RichTextRenderer } from './RichTextRenderer' import { RichTextRenderer } from './RichTextRenderer'
import type { BlockByType } from '@c2s/payload-contracts/types'
type TextBlockProps = Omit<TextBlockType, 'blockType'> type TextBlockProps = Omit<BlockByType<'text-block'>, 'blockType' | 'blockName'>
export function TextBlock({ export function TextBlock({
content, content,
alignment = 'left', width = 'medium',
maxWidth = 'lg',
}: TextBlockProps) { }: TextBlockProps) {
const alignmentClasses = { const widthClasses: Record<string, string> = {
left: 'text-left', narrow: 'max-w-xl',
center: 'text-center mx-auto', medium: 'max-w-2xl',
right: 'text-right ml-auto',
}
const maxWidthClasses = {
sm: 'max-w-xl',
md: 'max-w-2xl',
lg: 'max-w-4xl',
full: 'max-w-none', full: 'max-w-none',
} }
return ( return (
<section className="py-12 md:py-16"> <section className="py-12 md:py-16">
<div className="container"> <div className="container">
<div className={cn(alignmentClasses[alignment], maxWidthClasses[maxWidth])}> <div className={cn('mx-auto', widthClasses[width || 'medium'])}>
<RichTextRenderer content={content} /> <RichTextRenderer content={content} />
</div> </div>
</div> </div>

View file

@ -3,87 +3,57 @@
import { useState } from 'react' import { useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { cn, extractYouTubeId, getPrivacyYouTubeUrl, getYouTubeThumbnail } from '@/lib/utils' import { cn, extractYouTubeId, getPrivacyYouTubeUrl, getYouTubeThumbnail } from '@/lib/utils'
import type { VideoBlock as VideoBlockType } from '@/lib/types' import { getMediaUrl } from '@/lib/payload-helpers'
import type { BlockByType } from '@c2s/payload-contracts/types'
type VideoBlockProps = Omit<VideoBlockType, 'blockType'> type VideoBlockProps = Omit<BlockByType<'video-block'>, 'blockType' | 'blockName'>
export function VideoBlock({ export function VideoBlock({
title, caption,
videoUrl, videoUrl,
thumbnailImage, thumbnail,
aspectRatio = '16:9', aspectRatio = '16:9',
sourceType,
}: VideoBlockProps) { }: VideoBlockProps) {
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const videoId = extractYouTubeId(videoUrl) const videoId = videoUrl ? extractYouTubeId(videoUrl) : null
const thumbnailUrl = thumbnailImage?.url || (videoId ? getYouTubeThumbnail(videoId) : null) const thumbUrl = getMediaUrl(thumbnail) || (videoId ? getYouTubeThumbnail(videoId) : null)
const embedUrl = videoId ? getPrivacyYouTubeUrl(videoId) : null const embedUrl = videoId ? getPrivacyYouTubeUrl(videoId) : null
const aspectClasses = { const aspectClasses: Record<string, string> = {
'16:9': 'aspect-video', '16:9': 'aspect-video',
'4:3': 'aspect-[4/3]', '4:3': 'aspect-[4/3]',
'1:1': 'aspect-square', '1:1': 'aspect-square',
'9:16': 'aspect-[9/16]',
'21:9': 'aspect-[21/9]',
} }
if (!embedUrl) { // Only handle embed/external sources with a URL for now
return null if (!embedUrl && sourceType !== 'upload') return null
}
return ( return (
<section className="py-12 md:py-16"> <section className="py-12 md:py-16">
<div className="container"> <div className="container">
{title && ( {caption && <h2 className="text-center mb-8">{caption}</h2>}
<h2 className="text-center mb-8">{title}</h2>
)}
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div <div className={cn('relative rounded-2xl overflow-hidden bg-espresso', aspectClasses[aspectRatio || '16:9'])}>
className={cn(
'relative rounded-2xl overflow-hidden bg-espresso',
aspectClasses[aspectRatio]
)}
>
{isPlaying ? ( {isPlaying ? (
<iframe <iframe
src={`${embedUrl}?autoplay=1&rel=0`} src={`${embedUrl}?autoplay=1&rel=0`}
title={title || 'Video'} title={caption || 'Video'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
/> />
) : ( ) : (
<button <button type="button" onClick={() => setIsPlaying(true)} className="absolute inset-0 w-full h-full group" aria-label="Video abspielen">
type="button" {thumbUrl && <Image src={thumbUrl} alt={caption || 'Video Thumbnail'} fill className="object-cover" />}
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" /> <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="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"> <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 <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">
xmlns="http://www.w3.org/2000/svg" <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" />
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> </svg>
</div> </div>
</div> </div>

View file

@ -3,43 +3,45 @@
import { useState } from 'react' import { useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { cn, extractYouTubeId, getPrivacyYouTubeUrl, getYouTubeThumbnail } from '@/lib/utils' import { cn, extractYouTubeId, getPrivacyYouTubeUrl, getYouTubeThumbnail } from '@/lib/utils'
import type { VideoEmbedBlock as VideoEmbedBlockType } from '@/lib/types' import { getMediaUrl } from '@/lib/payload-helpers'
import type { BlockByType } from '@c2s/payload-contracts/types'
type VideoEmbedBlockProps = Omit<VideoEmbedBlockType, 'blockType'> type VideoEmbedBlockProps = Omit<BlockByType<'video-embed-block'>, 'blockType' | 'blockName'>
export function VideoEmbedBlock({ export function VideoEmbedBlock({
videoUrl,
title, title,
aspectRatio = '16:9', videoSource,
youtubeUrl,
vimeoUrl,
customUrl,
thumbnail,
caption,
privacyMode = true, privacyMode = true,
autoplay = false, aspectRatio = '16:9',
showControls = true, playbackOptions,
thumbnailImage,
}: VideoEmbedBlockProps) { }: VideoEmbedBlockProps) {
const autoplay = playbackOptions?.autoplay ?? false
const showControls = playbackOptions?.showControls ?? true
const [isPlaying, setIsPlaying] = useState(autoplay) const [isPlaying, setIsPlaying] = useState(autoplay)
const videoId = extractYouTubeId(videoUrl) // Determine the video URL based on source
const rawUrl = videoSource === 'youtube' ? youtubeUrl : videoSource === 'vimeo' ? vimeoUrl : customUrl
const videoId = rawUrl ? extractYouTubeId(rawUrl) : null
const embedUrl = videoId const embedUrl = videoId
? privacyMode ? privacyMode ? getPrivacyYouTubeUrl(videoId) : `https://www.youtube.com/embed/${videoId}`
? getPrivacyYouTubeUrl(videoId)
: `https://www.youtube.com/embed/${videoId}`
: null : null
const thumbnailUrl = const thumbUrl = getMediaUrl(thumbnail) || (videoId ? getYouTubeThumbnail(videoId) : null)
thumbnailImage?.url || (videoId ? getYouTubeThumbnail(videoId) : null)
const aspectClasses = { const aspectClasses: Record<string, string> = {
'16:9': 'aspect-video', '16:9': 'aspect-video',
'4:3': 'aspect-[4/3]', '4:3': 'aspect-[4/3]',
'1:1': 'aspect-square', '1:1': 'aspect-square',
'9:16': 'aspect-[9/16]', '9:16': 'aspect-[9/16]',
} }
if (!embedUrl) { if (!embedUrl) return null
return null
}
// Build embed params
const params = new URLSearchParams() const params = new URLSearchParams()
if (isPlaying || autoplay) params.append('autoplay', '1') if (isPlaying || autoplay) params.append('autoplay', '1')
params.append('rel', '0') params.append('rel', '0')
@ -47,65 +49,30 @@ export function VideoEmbedBlock({
const embedSrc = `${embedUrl}?${params.toString()}` const embedSrc = `${embedUrl}?${params.toString()}`
return ( return (
<div <div className="py-8">
className={cn( {title && <h3 className="text-lg font-semibold mb-4 text-center">{title}</h3>}
'relative rounded-xl overflow-hidden bg-espresso', <div className={cn('relative rounded-xl overflow-hidden bg-espresso', aspectClasses[aspectRatio || '16:9'])}>
aspectClasses[aspectRatio] {isPlaying ? (
)} <iframe src={embedSrc} title={title || caption || 'Video'} allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen className="absolute inset-0 w-full h-full" />
> ) : (
{isPlaying ? ( <button type="button" onClick={() => setIsPlaying(true)} className="absolute inset-0 w-full h-full group" aria-label={`Video abspielen${title ? `: ${title}` : ''}`}>
<iframe {thumbUrl && <Image src={thumbUrl} alt={title || caption || 'Video Thumbnail'} fill className="object-cover" />}
src={embedSrc} <div className="absolute inset-0 bg-espresso/30 group-hover:bg-espresso/40 transition-colors" />
title={title || 'Video'} <div className="absolute inset-0 flex items-center justify-center">
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" <div className="w-14 h-14 md:w-16 md:h-16 rounded-full bg-soft-white/90 flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
allowFullScreen <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6 md:w-8 md:h-8 text-brass ml-0.5">
className="absolute inset-0 w-full h-full" <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>
<button
type="button"
onClick={() => setIsPlaying(true)}
className="absolute inset-0 w-full h-full group"
aria-label={`Video abspielen${title ? `: ${title}` : ''}`}
>
{thumbnailUrl && (
<Image
src={thumbnailUrl}
alt={title || 'Video Thumbnail'}
fill
className="object-cover"
/>
)}
{/* Overlay */}
<div className="absolute inset-0 bg-espresso/30 group-hover:bg-espresso/40 transition-colors" />
{/* Play Button */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-14 h-14 md:w-16 md:h-16 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-6 h-6 md:w-8 md:h-8 text-brass ml-0.5"
>
<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>
</div> {caption && (
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-espresso/80 to-transparent">
{/* Title */} <p className="text-soft-white font-medium">{caption}</p>
{title && ( </div>
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-espresso/80 to-transparent"> )}
<p className="text-soft-white font-medium">{title}</p> </button>
</div> )}
)} </div>
</button>
)}
</div> </div>
) )
} }

View file

@ -15,7 +15,7 @@ import { SeriesBlock } from './SeriesBlock'
import { SeriesDetailBlock } from './SeriesDetailBlock' import { SeriesDetailBlock } from './SeriesDetailBlock'
import { VideoEmbedBlock } from './VideoEmbedBlock' import { VideoEmbedBlock } from './VideoEmbedBlock'
import { StatsBlock } from './StatsBlock' import { StatsBlock } from './StatsBlock'
import type { Block } from '@/lib/types' import type { Block } from '@c2s/payload-contracts/types'
// Map block types to components // Map block types to components
const blockComponents: Record<string, React.ComponentType<Record<string, unknown>>> = { const blockComponents: Record<string, React.ComponentType<Record<string, unknown>>> = {

View file

@ -1,13 +1,14 @@
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import type { Navigation as NavigationType } from '@c2s/payload-contracts/types' import type { Navigation as NavigationType, SiteSetting, SocialLink } from '@c2s/payload-contracts/types'
import type { SiteSettings } from '@/lib/types' import { getMediaUrl, getMediaAlt, socialLinksToMap } from '@/lib/payload-helpers'
type FooterMenuItem = NonNullable<NavigationType['footerMenu']>[number] type FooterMenuItem = NonNullable<NavigationType['footerMenu']>[number]
interface FooterProps { interface FooterProps {
footerMenu: FooterMenuItem[] | null footerMenu: FooterMenuItem[] | null
settings: SiteSettings | null settings: SiteSetting | null
socialLinks?: SocialLink[]
} }
function getPageSlug(page: FooterMenuItem['page']): string | undefined { function getPageSlug(page: FooterMenuItem['page']): string | undefined {
@ -15,8 +16,10 @@ function getPageSlug(page: FooterMenuItem['page']): string | undefined {
return undefined return undefined
} }
export function Footer({ footerMenu, settings }: FooterProps) { export function Footer({ footerMenu, settings, socialLinks }: FooterProps) {
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const logoUrl = getMediaUrl(settings?.logo)
const socials = socialLinksToMap(socialLinks)
return ( return (
<footer className="bg-espresso text-soft-white"> <footer className="bg-espresso text-soft-white">
@ -26,10 +29,10 @@ export function Footer({ footerMenu, settings }: FooterProps) {
{/* Brand Column */} {/* Brand Column */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<Link href="/" className="inline-block mb-4"> <Link href="/" className="inline-block mb-4">
{settings?.logo ? ( {logoUrl ? (
<Image <Image
src={settings.logo.url} src={logoUrl}
alt={settings.siteName || 'BlogWoman'} alt={getMediaAlt(settings?.logo, settings?.siteName || 'BlogWoman')}
width={140} width={140}
height={35} height={35}
className="h-9 w-auto brightness-0 invert" className="h-9 w-auto brightness-0 invert"
@ -45,22 +48,22 @@ export function Footer({ footerMenu, settings }: FooterProps) {
</p> </p>
{/* Social Links */} {/* Social Links */}
{settings?.socialLinks && ( {Object.keys(socials).length > 0 && (
<div className="flex gap-4 mt-6"> <div className="flex gap-4 mt-6">
{settings.socialLinks.instagram && ( {socials.instagram && (
<SocialAnchor href={settings.socialLinks.instagram} label="Instagram" platform="instagram" /> <SocialAnchor href={socials.instagram} label="Instagram" platform="instagram" />
)} )}
{settings.socialLinks.youtube && ( {socials.youtube && (
<SocialAnchor href={settings.socialLinks.youtube} label="YouTube" platform="youtube" /> <SocialAnchor href={socials.youtube} label="YouTube" platform="youtube" />
)} )}
{settings.socialLinks.pinterest && ( {socials.facebook && (
<SocialAnchor href={settings.socialLinks.pinterest} label="Pinterest" platform="pinterest" /> <SocialAnchor href={socials.facebook} label="Facebook" platform="facebook" />
)} )}
{settings.socialLinks.tiktok && ( {socials.x && (
<SocialAnchor href={settings.socialLinks.tiktok} label="TikTok" platform="tiktok" /> <SocialAnchor href={socials.x} label="X" platform="x" />
)} )}
{settings.socialLinks.facebook && ( {socials.linkedin && (
<SocialAnchor href={settings.socialLinks.facebook} label="Facebook" platform="facebook" /> <SocialAnchor href={socials.linkedin} label="LinkedIn" platform="linkedin" />
)} )}
</div> </div>
)} )}
@ -83,29 +86,29 @@ export function Footer({ footerMenu, settings }: FooterProps) {
)} )}
{/* Contact Column */} {/* Contact Column */}
{(settings?.email || settings?.phone || settings?.address) && ( {(settings?.contact?.email || settings?.contact?.phone || settings?.address) && (
<div> <div>
<h3 className="text-xs font-bold tracking-[0.1em] uppercase text-brass mb-4"> <h3 className="text-xs font-bold tracking-[0.1em] uppercase text-brass mb-4">
Kontakt Kontakt
</h3> </h3>
<address className="not-italic space-y-2 text-sm"> <address className="not-italic space-y-2 text-sm">
{settings.email && ( {settings.contact?.email && (
<p> <p>
<a <a
href={`mailto:${settings.email}`} href={`mailto:${settings.contact.email}`}
className="text-soft-white hover:text-sand transition-colors" className="text-soft-white hover:text-sand transition-colors"
> >
{settings.email} {settings.contact.email}
</a> </a>
</p> </p>
)} )}
{settings.phone && ( {settings.contact?.phone && (
<p> <p>
<a <a
href={`tel:${settings.phone.replace(/\s/g, '')}`} href={`tel:${settings.contact.phone.replace(/\s/g, '')}`}
className="text-soft-white hover:text-sand transition-colors" className="text-soft-white hover:text-sand transition-colors"
> >
{settings.phone} {settings.contact.phone}
</a> </a>
</p> </p>
)} )}
@ -183,18 +186,6 @@ function SocialIcon({ platform }: { platform: string }) {
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /> <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg> </svg>
) )
case 'pinterest':
return (
<svg className={iconClass} fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.373 0 0 5.372 0 12c0 5.084 3.163 9.426 7.627 11.174-.105-.949-.2-2.405.042-3.441.218-.937 1.407-5.965 1.407-5.965s-.359-.719-.359-1.782c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738a.36.36 0 01.083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.631-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12 24c6.627 0 12-5.373 12-12 0-6.628-5.373-12-12-12z" />
</svg>
)
case 'tiktok':
return (
<svg className={iconClass} fill="currentColor" viewBox="0 0 24 24">
<path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" />
</svg>
)
default: default:
return ( return (
<svg className={iconClass} fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className={iconClass} fill="none" viewBox="0 0 24 24" stroke="currentColor">

View file

@ -5,18 +5,19 @@ import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { Navigation } from './Navigation' import { Navigation } from './Navigation'
import { MobileMenu } from './MobileMenu' import { MobileMenu } from './MobileMenu'
import type { Navigation as NavigationType } from '@c2s/payload-contracts/types' import type { Navigation as NavigationType, SiteSetting } from '@c2s/payload-contracts/types'
import type { SiteSettings } from '@/lib/types' import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
type MainMenuItem = NonNullable<NavigationType['mainMenu']>[number] type MainMenuItem = NonNullable<NavigationType['mainMenu']>[number]
interface HeaderProps { interface HeaderProps {
mainMenu: MainMenuItem[] | null mainMenu: MainMenuItem[] | null
settings: SiteSettings | null settings: SiteSetting | null
} }
export function Header({ mainMenu, settings }: HeaderProps) { export function Header({ mainMenu, settings }: HeaderProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const logoUrl = getMediaUrl(settings?.logo)
return ( return (
<header className="sticky top-0 z-[100] bg-ivory/95 backdrop-blur-[10px] border-b border-warm-gray"> <header className="sticky top-0 z-[100] bg-ivory/95 backdrop-blur-[10px] border-b border-warm-gray">
@ -27,10 +28,10 @@ export function Header({ mainMenu, settings }: HeaderProps) {
href="/" href="/"
className="font-headline text-2xl font-semibold text-espresso tracking-tight hover:text-brass transition-colors" className="font-headline text-2xl font-semibold text-espresso tracking-tight hover:text-brass transition-colors"
> >
{settings?.logo ? ( {logoUrl ? (
<Image <Image
src={settings.logo.url} src={logoUrl}
alt={settings.siteName || 'BlogWoman'} alt={getMediaAlt(settings?.logo, settings?.siteName || 'BlogWoman')}
width={160} width={160}
height={40} height={40}
className="h-10 w-auto" className="h-10 w-auto"

View file

@ -2,25 +2,22 @@
* Payload CMS API functions powered by @c2s/payload-contracts * Payload CMS API functions powered by @c2s/payload-contracts
* *
* Uses the shared API client for transport (tenant isolation, fetch caching). * Uses the shared API client for transport (tenant isolation, fetch caching).
* Returns data typed with local interfaces for component compatibility. * All types come directly from contracts no bridge casts needed.
*
* Navigation uses contracts types directly (schema matches).
* Other types use 'as unknown as' bridge since local types differ
* from CMS types (meta vs seo, id string vs number, etc.).
*/ */
import { cms } from "./cms" import { cms } from "./cms"
import type { Navigation } from "@c2s/payload-contracts/types"
import type { import type {
Navigation,
Page, Page,
Post, Post,
Favorite, Favorite,
Series, Series,
Testimonial, Testimonial,
FAQ, Faq,
SeoSettings, SiteSetting,
SeoSetting,
SocialLink,
PaginatedResponse, PaginatedResponse,
SiteSettings, } from "@c2s/payload-contracts/types"
} from "./types"
export type { Navigation } export type { Navigation }
@ -28,11 +25,10 @@ const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID || "9"
// Pages // Pages
export async function getPage(slug: string, locale = "de"): Promise<Page | null> { export async function getPage(slug: string, locale = "de"): Promise<Page | null> {
const result = await cms.pages.getPage(slug, { return cms.pages.getPage(slug, {
locale: locale as "de" | "en", locale: locale as "de" | "en",
depth: 2, depth: 2,
}) })
return result as unknown as Page | null
} }
export async function getPages(options: { export async function getPages(options: {
@ -40,22 +36,20 @@ export async function getPages(options: {
page?: number page?: number
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<Page>> { } = {}): Promise<PaginatedResponse<Page>> {
const result = await cms.pages.getPages({ return cms.pages.getPages({
limit: options.limit || 100, limit: options.limit || 100,
page: options.page || 1, page: options.page || 1,
locale: (options.locale || "de") as "de" | "en", locale: (options.locale || "de") as "de" | "en",
depth: 1, depth: 1,
}) })
return result as unknown as PaginatedResponse<Page>
} }
// Posts // Posts
export async function getPost(slug: string, locale = "de"): Promise<Post | null> { export async function getPost(slug: string, locale = "de"): Promise<Post | null> {
const result = await cms.posts.getPost(slug, { return cms.posts.getPost(slug, {
locale: locale as "de" | "en", locale: locale as "de" | "en",
depth: 2, depth: 2,
}) })
return result as unknown as Post | null
} }
export async function getPosts(options: { export async function getPosts(options: {
@ -71,7 +65,7 @@ export async function getPosts(options: {
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {}
if (options.featured) where["isFeatured][equals"] = "true" if (options.featured) where["isFeatured][equals"] = "true"
const result = await cms.posts.getPosts({ return await cms.posts.getPosts({
type: options.type, type: options.type,
category: options.category, category: options.category,
series: options.series, series: options.series,
@ -80,14 +74,12 @@ export async function getPosts(options: {
locale: (options.locale || "de") as "de" | "en", locale: (options.locale || "de") as "de" | "en",
where, where,
}) })
return result as unknown as PaginatedResponse<Post>
} catch { } catch {
return { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null } return { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
} }
} }
// Navigation — single document per tenant with mainMenu + footerMenu // Navigation — single document per tenant with mainMenu + footerMenu
// Uses contracts Navigation type directly (no bridge needed)
export async function getNavigation(): Promise<Navigation | null> { export async function getNavigation(): Promise<Navigation | null> {
try { try {
return await cms.navigation.getNavigation({ depth: 2 }) return await cms.navigation.getNavigation({ depth: 2 })
@ -97,36 +89,47 @@ export async function getNavigation(): Promise<Navigation | null> {
} }
// Site Settings // Site Settings
export async function getSiteSettings(): Promise<SiteSettings | null> { export async function getSiteSettings(): Promise<SiteSetting | null> {
try { try {
const result = await cms.settings.getSiteSettings({ depth: 2 }) return await cms.settings.getSiteSettings({ depth: 2 })
return result as unknown as SiteSettings | null
} catch { } catch {
return null return null
} }
} }
// SEO Settings (Global) // SEO Settings (Global)
export async function getSeoSettings(): Promise<SeoSettings | null> { export async function getSeoSettings(): Promise<SeoSetting | null> {
try { try {
const result = await cms.settings.getSeoSettings() return await cms.settings.getSeoSettings()
return result as unknown as SeoSettings | null
} catch { } catch {
return null return null
} }
} }
// Social Links
export async function getSocialLinks(): Promise<SocialLink[]> {
try {
const result = await cms.client.getCollection("social-links", {
limit: 20,
depth: 0,
where: { "isActive][equals": "true" },
})
return result.docs as SocialLink[]
} catch {
return []
}
}
// Testimonials // Testimonials
export async function getTestimonials(options: { export async function getTestimonials(options: {
limit?: number limit?: number
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<Testimonial>> { } = {}): Promise<PaginatedResponse<Testimonial>> {
const result = await cms.client.getCollection("testimonials", { return await cms.client.getCollection("testimonials", {
limit: options.limit || 10, limit: options.limit || 10,
locale: (options.locale || "de") as "de" | "en", locale: (options.locale || "de") as "de" | "en",
depth: 1, depth: 1,
}) }) as PaginatedResponse<Testimonial>
return result as unknown as PaginatedResponse<Testimonial>
} }
// FAQs // FAQs
@ -134,15 +137,14 @@ export async function getFAQs(options: {
category?: string category?: string
limit?: number limit?: number
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<FAQ>> { } = {}): Promise<PaginatedResponse<Faq>> {
const result = await cms.client.getCollection("faqs", { return await cms.client.getCollection("faqs", {
limit: options.limit || 50, limit: options.limit || 50,
locale: (options.locale || "de") as "de" | "en", locale: (options.locale || "de") as "de" | "en",
sort: "order", sort: "order",
depth: 1, depth: 1,
where: options.category ? { "category][equals": options.category } : undefined, where: options.category ? { "category][equals": options.category } : undefined,
}) }) as PaginatedResponse<Faq>
return result as unknown as PaginatedResponse<FAQ>
} }
// BlogWoman: Favorites // BlogWoman: Favorites
@ -158,14 +160,13 @@ export async function getFavorites(options: {
if (options.category) where["category][equals"] = options.category if (options.category) where["category][equals"] = options.category
if (options.badge) where["badge][equals"] = options.badge if (options.badge) where["badge][equals"] = options.badge
const result = await cms.client.getCollection("favorites", { return await cms.client.getCollection("favorites", {
limit: options.limit || 12, limit: options.limit || 12,
page: options.page || 1, page: options.page || 1,
locale: (options.locale || "de") as "de" | "en", locale: (options.locale || "de") as "de" | "en",
depth: 1, depth: 1,
where, where,
}) }) as PaginatedResponse<Favorite>
return result as unknown as PaginatedResponse<Favorite>
} catch { } catch {
return { docs: [], totalDocs: 0, limit: 12, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null } return { docs: [], totalDocs: 0, limit: 12, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
} }
@ -177,8 +178,8 @@ export async function getFavorite(slug: string, locale = "de"): Promise<Favorite
depth: 1, depth: 1,
where: { "slug][equals": slug, "isActive][equals": "true" }, where: { "slug][equals": slug, "isActive][equals": "true" },
limit: 1, limit: 1,
}) }) as PaginatedResponse<Favorite>
return (data.docs[0] as unknown as Favorite) ?? null return data.docs[0] ?? null
} }
// BlogWoman: Series // BlogWoman: Series
@ -187,13 +188,12 @@ export async function getSeries(options: {
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<Series>> { } = {}): Promise<PaginatedResponse<Series>> {
try { try {
const result = await cms.client.getCollection("series", { return await cms.client.getCollection("series", {
limit: options.limit || 20, limit: options.limit || 20,
locale: (options.locale || "de") as "de" | "en", locale: (options.locale || "de") as "de" | "en",
depth: 2, depth: 2,
where: { "isActive][equals": "true" }, where: { "isActive][equals": "true" },
}) }) as PaginatedResponse<Series>
return result as unknown as PaginatedResponse<Series>
} catch { } catch {
return { docs: [], totalDocs: 0, limit: 20, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null } return { docs: [], totalDocs: 0, limit: 20, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
} }
@ -205,8 +205,8 @@ export async function getSeriesBySlug(slug: string, locale = "de"): Promise<Seri
depth: 2, depth: 2,
where: { "slug][equals": slug, "isActive][equals": "true" }, where: { "slug][equals": slug, "isActive][equals": "true" },
limit: 1, limit: 1,
}) }) as PaginatedResponse<Series>
return (data.docs[0] as unknown as Series) ?? null return data.docs[0] ?? null
} }
// Newsletter Subscription // Newsletter Subscription

View file

@ -0,0 +1,74 @@
import type { Media, SocialLink } from '@c2s/payload-contracts/types'
/**
* Resolve a Payload relationship field.
* When depth >= 1, relationships are populated objects.
* When depth = 0, they're just numbers (IDs).
* This helper narrows the union `number | T | null | undefined` `T | null`.
*/
export function resolveRelation<T>(value: number | T | null | undefined): T | null {
if (value == null || typeof value === 'number') return null
return value
}
/**
* Resolve a Media relationship field.
* Shorthand for resolveRelation<Media>.
*/
export function resolveMedia(media: number | Media | null | undefined): Media | null {
return resolveRelation<Media>(media)
}
/**
* Get the URL of a resolved media field, optionally for a specific size.
*/
export function getMediaUrl(
media: number | Media | null | undefined,
size?: keyof NonNullable<Media['sizes']>
): string | undefined {
const resolved = resolveMedia(media)
if (!resolved) return undefined
if (size && resolved.sizes?.[size]?.url) {
return resolved.sizes[size]!.url!
}
return resolved.url ?? undefined
}
/**
* Get the alt text of a resolved media field with a fallback.
*/
export function getMediaAlt(media: number | Media | null | undefined, fallback = ''): string {
const resolved = resolveMedia(media)
return resolved?.alt ?? fallback
}
/**
* Resolve an array of Payload relationship fields.
* Filters out unresolved (number) entries.
*/
export function resolveRelationArray<T>(items: (number | T)[] | null | undefined): T[] {
if (!items) return []
return items.filter((item): item is T => typeof item !== 'number')
}
/**
* Get the author name from a resolved or unresolved author field.
*/
export function getAuthorName(author: number | { name: string } | null | undefined): string | null {
if (author == null || typeof author === 'number') return null
return author.name
}
/**
* Convert a SocialLink[] array into a Record<platform, url> map.
*/
export function socialLinksToMap(links: SocialLink[] | null | undefined): Record<string, string> {
if (!links) return {}
const map: Record<string, string> = {}
for (const link of links) {
if (link.isActive !== false && link.url) {
map[link.platform] = link.url
}
}
return map
}

View file

@ -1,15 +1,16 @@
import { absoluteUrl, getImageUrl } from './utils' import { absoluteUrl } from './utils'
import type { Post, Series, Favorite, SiteSettings, Author } from './types' import { getMediaUrl, resolveRelation } from './payload-helpers'
import type { Post, Series, Favorite, SiteSetting, Author } from '@c2s/payload-contracts/types'
/** /**
* Generate WebSite schema for the homepage * Generate WebSite schema for the homepage
*/ */
export function generateWebSiteSchema(settings?: SiteSettings | null) { export function generateWebSiteSchema(settings?: SiteSetting | null) {
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'WebSite', '@type': 'WebSite',
name: settings?.siteName || 'BlogWoman', name: settings?.siteName || 'BlogWoman',
description: settings?.siteDescription || 'Lifestyle-Blog für moderne Frauen', description: settings?.seo?.defaultMetaDescription || 'Lifestyle-Blog fuer moderne Frauen',
url: absoluteUrl('/'), url: absoluteUrl('/'),
potentialAction: { potentialAction: {
'@type': 'SearchAction', '@type': 'SearchAction',
@ -25,20 +26,14 @@ export function generateWebSiteSchema(settings?: SiteSettings | null) {
/** /**
* Generate Organization schema * Generate Organization schema
*/ */
export function generateOrganizationSchema(settings?: SiteSettings | null) { export function generateOrganizationSchema(settings?: SiteSetting | null) {
const logoUrl = settings?.logo?.url || '/logo.png' const logoUrl = getMediaUrl(settings?.logo) || '/logo.png'
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Organization', '@type': 'Organization',
name: settings?.siteName || 'BlogWoman', name: settings?.siteName || 'BlogWoman',
url: absoluteUrl('/'), url: absoluteUrl('/'),
logo: absoluteUrl(logoUrl), logo: absoluteUrl(logoUrl),
sameAs: [
settings?.socialLinks?.instagram,
settings?.socialLinks?.youtube,
settings?.socialLinks?.pinterest,
settings?.socialLinks?.tiktok,
].filter(Boolean),
} }
} }
@ -47,11 +42,11 @@ export function generateOrganizationSchema(settings?: SiteSettings | null) {
*/ */
export function generateBlogPostingSchema( export function generateBlogPostingSchema(
post: Post, post: Post,
settings?: SiteSettings | null settings?: SiteSetting | null
) { ) {
const author = post.author as Author | undefined const author = resolveRelation<Author>(post.author)
const imageUrl = getImageUrl(post.featuredImage) const imageUrl = getMediaUrl(post.featuredImage)
const logoUrl = settings?.logo?.url || '/logo.png' const logoUrl = getMediaUrl(settings?.logo) || '/logo.png'
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
@ -131,7 +126,6 @@ export function generateVideoSchema(
thumbnailUrl?: string, thumbnailUrl?: string,
uploadDate?: string uploadDate?: string
) { ) {
// Extract YouTube video ID
const videoIdMatch = videoUrl.match( const videoIdMatch = videoUrl.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/ /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
) )
@ -156,7 +150,7 @@ export function generateVideoSchema(
* Generate Product schema for affiliate products * Generate Product schema for affiliate products
*/ */
export function generateProductSchema(favorite: Favorite) { export function generateProductSchema(favorite: Favorite) {
const imageUrl = getImageUrl(favorite.image) const imageUrl = getMediaUrl(favorite.image)
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
@ -164,10 +158,10 @@ export function generateProductSchema(favorite: Favorite) {
name: favorite.title, name: favorite.title,
description: favorite.description, description: favorite.description,
image: imageUrl || undefined, image: imageUrl || undefined,
offers: favorite.price offers: favorite.price != null
? { ? {
'@type': 'Offer', '@type': 'Offer',
price: favorite.price.replace(/[^0-9.,]/g, ''), price: String(favorite.price),
priceCurrency: 'EUR', priceCurrency: 'EUR',
availability: 'https://schema.org/InStock', availability: 'https://schema.org/InStock',
url: favorite.affiliateUrl, url: favorite.affiliateUrl,
@ -199,8 +193,8 @@ export function generatePersonSchema(author: Author) {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Person', '@type': 'Person',
name: author.name, name: author.name,
jobTitle: author.role,
image: author.avatar?.url, image: getMediaUrl(author.avatar),
url: absoluteUrl('/about'), url: absoluteUrl('/about'),
} }
} }
@ -209,13 +203,13 @@ export function generatePersonSchema(author: Author) {
* Generate CollectionPage schema for series * Generate CollectionPage schema for series
*/ */
export function generateSeriesSchema(series: Series) { export function generateSeriesSchema(series: Series) {
const imageUrl = getImageUrl(series.coverImage) const imageUrl = getMediaUrl(series.coverImage)
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'CollectionPage', '@type': 'CollectionPage',
name: series.title, name: series.title,
description: series.meta?.description, description: series.tagline || undefined,
image: imageUrl || undefined, image: imageUrl || undefined,
url: absoluteUrl(`/serien/${series.slug}`), url: absoluteUrl(`/serien/${series.slug}`),
...(series.youtubePlaylistId && { ...(series.youtubePlaylistId && {

View file

@ -1,484 +1,55 @@
// Payload CMS Types for BlogWoman /**
* Type re-exports from @c2s/payload-contracts
*
* Provides a stable import path for all CMS types.
* Components import from '@/lib/types' contracts types flow through.
*/
export interface Media { // Collection types
id: string export type {
url: string Page,
alt?: string Post,
width?: number Media,
height?: number Category,
mimeType?: string Tag,
filename?: string Author,
} Testimonial,
Faq,
Series,
Favorite,
SiteSetting,
SeoSetting,
SocialLink,
Navigation,
Form,
} from '@c2s/payload-contracts/types'
// Block types
export type { Block, BlockByType } from '@c2s/payload-contracts/types'
// API types
export type { PaginatedResponse } from '@c2s/payload-contracts/types'
// Aliases for backward compatibility
export type { Faq as FAQ } from '@c2s/payload-contracts/types'
export type { SiteSetting as SiteSettings } from '@c2s/payload-contracts/types'
/**
* Rich text content structure (Lexical editor)
* Not exported from contracts defined inline.
*/
export interface RichText { export interface RichText {
root: { root: {
children: unknown[]
direction: string | null
format: string
indent: number
type: string type: string
children: {
type: string
version: number
[k: string]: unknown
}[]
direction: ('ltr' | 'rtl') | null
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''
indent: number
version: number version: number
} }
} [k: string]: unknown
// Navigation
export interface NavigationItem {
id: string
label: string
type: 'internal' | 'external' | 'submenu'
page?: { slug: string }
url?: string
openInNewTab?: boolean
children?: NavigationItem[]
}
export interface Navigation {
id: string
type: 'header' | 'footer' | 'mobile'
items: NavigationItem[]
}
// Site Settings
export interface Address {
street?: string
additionalLine?: string
zip?: string
city?: string
state?: string
country?: string
}
export interface SiteSettings {
id: string
siteName: string
siteDescription?: string
logo?: Media
favicon?: Media
description?: string
email?: string
phone?: string
address?: Address | string
socialLinks?: {
instagram?: string
youtube?: string
pinterest?: string
tiktok?: string
facebook?: string
twitter?: string
}
}
// SEO Settings (Global)
export interface SeoMetaDefaults {
titleSuffix?: string
defaultDescription?: string
defaultImage?: Media
}
export interface SeoRobotsSettings {
indexing?: boolean
additionalDisallow?: string[]
}
export interface SeoVerificationSettings {
google?: string
bing?: string
yandex?: string
}
export interface SeoSettings {
metaDefaults?: SeoMetaDefaults
robots?: SeoRobotsSettings
verification?: SeoVerificationSettings
}
// Page
export interface PageMeta {
title?: string
description?: string
image?: Media
noIndex?: boolean
noFollow?: boolean
}
export interface Page {
id: string
title: string
slug: string
status: 'draft' | 'published'
meta?: PageMeta
layout: Block[]
publishedAt?: string
updatedAt: string
createdAt: string
}
// Posts
export interface Author {
id: string
name: string
role?: string
bio?: string
avatar?: Media
}
export interface Category {
id: string
title: string
slug: string
}
export interface Post {
id: string
title: string
slug: string
type: 'blog' | 'news' | 'press' | 'announcement'
status: 'draft' | 'published'
excerpt?: string
content?: RichText
featuredImage?: Media
author?: Author | string
series?: Series | string
categories?: Category[]
tags?: string[]
layout?: Block[]
publishedAt?: string
updatedAt: string
createdAt: string
meta?: PageMeta
}
// Testimonials
export interface Testimonial {
id: string
quote: string
authorName: string
authorTitle?: string
authorCompany?: string
authorImage?: Media
rating?: number
}
// FAQ
export interface FAQ {
id: string
question: string
answer: RichText
category?: string
order?: number
}
// BlogWoman-specific: Favorites (Affiliate Products)
export type FavoriteCategory = 'fashion' | 'beauty' | 'travel' | 'tech' | 'home'
export type FavoriteBadge = 'investment-piece' | 'daily-driver' | 'grfi-approved' | 'new' | 'bestseller'
export type FavoritePriceRange = 'budget' | 'mid' | 'premium' | 'luxury'
export interface Favorite {
id: string
title: string
slug: string
description?: string
image?: Media
affiliateUrl: string
price?: string
category?: FavoriteCategory | string
badge?: FavoriteBadge
priceRange?: FavoritePriceRange
isActive: boolean
}
// BlogWoman-specific: Series (YouTube Series)
export interface Series {
id: string
title: string
slug: string
description?: RichText
logo?: Media
coverImage?: Media
brandColor?: string
youtubePlaylistId?: string
isActive: boolean
meta?: PageMeta
}
// Blocks
export interface BaseBlock {
id?: string
blockType: string
blockName?: string
}
export interface HeroBlock extends BaseBlock {
blockType: 'hero-block'
heading: string
subheading?: string
backgroundImage?: Media
ctaText?: string
ctaLink?: string
alignment?: 'left' | 'center' | 'right'
overlay?: boolean
overlayOpacity?: number
}
export interface HeroSliderBlock extends BaseBlock {
blockType: 'hero-slider-block'
slides: Array<{
heading: string
subheading?: string
backgroundImage?: Media
ctaText?: string
ctaLink?: string
}>
autoplay?: boolean
interval?: number
}
export interface TextBlock extends BaseBlock {
blockType: 'text-block'
content: RichText
alignment?: 'left' | 'center' | 'right'
maxWidth?: 'sm' | 'md' | 'lg' | 'full'
}
export interface ImageTextBlock extends BaseBlock {
blockType: 'image-text-block'
heading?: string
content: RichText
image: Media
imagePosition: 'left' | 'right'
backgroundColor?: 'white' | 'ivory' | 'sand'
}
export interface CardGridBlock extends BaseBlock {
blockType: 'card-grid-block'
title?: string
subtitle?: string
cards: Array<{
title: string
description?: string
image?: Media
link?: string
icon?: string
}>
columns?: 2 | 3 | 4
}
export interface CTABlock extends BaseBlock {
blockType: 'cta-block'
heading: string
subheading?: string
buttonText: string
buttonLink: string
backgroundColor?: 'brass' | 'espresso' | 'bordeaux'
backgroundImage?: Media
}
export interface DividerBlock extends BaseBlock {
blockType: 'divider-block'
style?: 'line' | 'dots' | 'space'
text?: string
}
export interface PostsListBlock extends BaseBlock {
blockType: 'posts-list-block'
title?: string
subtitle?: string
postType?: 'blog' | 'news' | 'press' | 'announcement'
layout?: 'grid' | 'list' | 'featured' | 'compact' | 'masonry'
columns?: 2 | 3 | 4
limit?: number
showFeaturedOnly?: boolean
filterByCategory?: string
showExcerpt?: boolean
showDate?: boolean
showAuthor?: boolean
showCategory?: boolean
showPagination?: boolean
backgroundColor?: 'white' | 'ivory' | 'sand'
}
export interface TestimonialsBlock extends BaseBlock {
blockType: 'testimonials-block'
title?: string
subtitle?: string
displayMode: 'all' | 'selected'
selectedTestimonials?: Testimonial[]
layout?: 'carousel' | 'grid' | 'list'
}
export interface FAQBlock extends BaseBlock {
blockType: 'faq-block'
title?: string
subtitle?: string
displayMode: 'all' | 'selected' | 'byCategory'
selectedFaqs?: FAQ[]
filterCategory?: string
layout?: 'accordion' | 'list' | 'grid'
expandFirst?: boolean
showSchema?: boolean
}
export interface NewsletterBlock extends BaseBlock {
blockType: 'newsletter-block'
title?: string
subtitle?: string
buttonText?: string
layout?: 'inline' | 'stacked' | 'with-image' | 'minimal' | 'card'
backgroundImage?: Media
showPrivacyNote?: boolean
source?: string
showFirstName?: boolean
}
export interface ContactFormBlock extends BaseBlock {
blockType: 'contact-form-block'
title?: string
subtitle?: string
formId?: number
showName?: boolean
showPhone?: boolean
showSubject?: boolean
successMessage?: string
}
export interface VideoBlock extends BaseBlock {
blockType: 'video-block'
title?: string
videoUrl: string
thumbnailImage?: Media
aspectRatio?: '16:9' | '4:3' | '1:1'
}
export interface TimelineBlock extends BaseBlock {
blockType: 'timeline-block'
title?: string
items: Array<{
year: string
title: string
description?: string
}>
}
export interface ProcessStepsBlock extends BaseBlock {
blockType: 'process-steps-block'
title?: string
subtitle?: string
steps: Array<{
number: number
title: string
description?: string
icon?: string
}>
}
// BlogWoman-specific blocks
export interface FavoritesBlock extends BaseBlock {
blockType: 'favorites-block'
title?: string
subtitle?: string
displayMode: 'all' | 'selected' | 'byCategory'
selectedFavorites?: Favorite[]
filterCategory?: FavoriteCategory
layout?: 'grid' | 'list' | 'carousel'
columns?: 2 | 3 | 4
limit?: number
showPrice?: boolean
showBadge?: boolean
}
export interface SeriesBlock extends BaseBlock {
blockType: 'series-block'
title?: string
subtitle?: string
displayMode: 'all' | 'selected'
selectedSeries?: Series[]
layout?: 'grid' | 'list' | 'featured'
showDescription?: boolean
}
export interface SeriesDetailBlock extends BaseBlock {
blockType: 'series-detail-block'
series: Series
layout?: 'hero' | 'compact' | 'sidebar'
showLogo?: boolean
showPlaylistLink?: boolean
useBrandColor?: boolean
}
export interface VideoEmbedBlock extends BaseBlock {
blockType: 'video-embed-block'
videoUrl: string
title?: string
aspectRatio?: '16:9' | '4:3' | '1:1' | '9:16'
privacyMode?: boolean
autoplay?: boolean
showControls?: boolean
thumbnailImage?: Media
}
export interface FeaturedContentBlock extends BaseBlock {
blockType: 'featured-content-block'
title?: string
subtitle?: string
items: Array<{
type: 'post' | 'video' | 'favorite' | 'series' | 'external'
post?: Post
video?: { title: string; videoUrl: string; thumbnailImage?: Media }
favorite?: Favorite
series?: Series
externalUrl?: string
externalTitle?: string
externalImage?: Media
}>
layout?: 'grid' | 'masonry' | 'featured'
}
export interface StatsBlock extends BaseBlock {
blockType: 'stats-block'
title?: string
subtitle?: string
stats: Array<{
value: string
label: string
description?: string
}>
backgroundColor?: 'ivory' | 'soft-white' | 'sand'
layout?: 'row' | 'grid'
}
export type Block =
| HeroBlock
| HeroSliderBlock
| TextBlock
| ImageTextBlock
| CardGridBlock
| CTABlock
| DividerBlock
| PostsListBlock
| TestimonialsBlock
| FAQBlock
| NewsletterBlock
| ContactFormBlock
| VideoBlock
| TimelineBlock
| ProcessStepsBlock
| FavoritesBlock
| SeriesBlock
| SeriesDetailBlock
| VideoEmbedBlock
| FeaturedContentBlock
| StatsBlock
// API Response Types
export interface PaginatedResponse<T> {
docs: T[]
totalDocs: number
limit: number
totalPages: number
page: number
pagingCounter: number
hasPrevPage: boolean
hasNextPage: boolean
prevPage: number | null
nextPage: number | null
} }