mirror of
https://github.com/complexcaresolutions/frontend.blogwoman.de.git
synced 2026-03-17 15:04:01 +00:00
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:
parent
7235d8b910
commit
3a8693289f
37 changed files with 896 additions and 2378 deletions
|
|
@ -10,7 +10,7 @@ importers:
|
|||
dependencies:
|
||||
'@c2s/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:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
|
|
@ -125,8 +125,8 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#a0eea9649d35ec2a4554632554d53799a36b7f4b':
|
||||
resolution: {commit: a0eea9649d35ec2a4554632554d53799a36b7f4b, repo: git@github.com:complexcaresolutions/payload-contracts.git, type: git}
|
||||
'@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#d8e16db5357e5fbac9f701171894a4e641db3df8':
|
||||
resolution: {commit: d8e16db5357e5fbac9f701171894a4e641db3df8, repo: git@github.com:complexcaresolutions/payload-contracts.git, type: git}
|
||||
version: 1.0.0
|
||||
peerDependencies:
|
||||
react: ^19.0.0
|
||||
|
|
@ -2048,7 +2048,7 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@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:
|
||||
react: 19.2.1
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Metadata } from 'next'
|
|||
import { notFound } from 'next/navigation'
|
||||
import { getPage, getPages, getSeoSettings } from '@/lib/api'
|
||||
import { BlockRenderer } from '@/components/blocks'
|
||||
import { getMediaUrl } from '@/lib/payload-helpers'
|
||||
import { generateBreadcrumbSchema } from '@/lib/structuredData'
|
||||
|
||||
interface PageProps {
|
||||
|
|
@ -17,7 +18,6 @@ export async function generateStaticParams() {
|
|||
slug: page.slug,
|
||||
}))
|
||||
} catch {
|
||||
// Return empty array if API is unavailable during build
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -34,12 +34,12 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||
}
|
||||
|
||||
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 description =
|
||||
page.meta?.description || seoSettings?.metaDefaults?.defaultDescription
|
||||
page.seo?.metaDescription || seoSettings?.metaDefaults?.defaultDescription
|
||||
const image =
|
||||
page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url
|
||||
getMediaUrl(page.seo?.ogImage) || getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -56,10 +56,6 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||
description: description || undefined,
|
||||
images: image ? [image] : undefined,
|
||||
},
|
||||
robots: {
|
||||
index: !page.meta?.noIndex,
|
||||
follow: !page.meta?.noFollow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import Image from 'next/image'
|
|||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
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 { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
|
||||
|
||||
|
|
@ -20,14 +21,14 @@ export async function generateMetadata({ params }: AnnouncementPostPageProps): P
|
|||
if (!post) return {}
|
||||
|
||||
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 description =
|
||||
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
|
||||
post.seo?.metaDescription || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
|
||||
const image =
|
||||
post.meta?.image?.url ||
|
||||
post.featuredImage?.url ||
|
||||
seoSettings?.metaDefaults?.defaultImage?.url
|
||||
getMediaUrl(post.seo?.ogImage) ||
|
||||
getMediaUrl(post.featuredImage) ||
|
||||
getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -44,10 +45,6 @@ export async function generateMetadata({ params }: AnnouncementPostPageProps): P
|
|||
description: description || 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()
|
||||
}
|
||||
|
||||
const imageUrl = getImageUrl(post.featuredImage)
|
||||
const imageUrl = getMediaUrl(post.featuredImage)
|
||||
const blogSchema = generateBlogPostingSchema(post, settings)
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ 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">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.featuredImage?.alt || post.title}
|
||||
alt={getMediaAlt(post.featuredImage, post.title)}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import Image from 'next/image'
|
|||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
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 { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
|
||||
import type { Author } from '@c2s/payload-contracts/types'
|
||||
|
||||
interface BlogPostPageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
|
|
@ -20,14 +22,14 @@ export async function generateMetadata({ params }: BlogPostPageProps): Promise<M
|
|||
if (!post) return {}
|
||||
|
||||
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 description =
|
||||
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
|
||||
post.seo?.metaDescription || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
|
||||
const image =
|
||||
post.meta?.image?.url ||
|
||||
post.featuredImage?.url ||
|
||||
seoSettings?.metaDefaults?.defaultImage?.url
|
||||
getMediaUrl(post.seo?.ogImage) ||
|
||||
getMediaUrl(post.featuredImage) ||
|
||||
getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -44,10 +46,6 @@ export async function generateMetadata({ params }: BlogPostPageProps): Promise<M
|
|||
description: description || 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()
|
||||
}
|
||||
|
||||
const imageUrl = getImageUrl(post.featuredImage)
|
||||
const imageUrl = getMediaUrl(post.featuredImage)
|
||||
const blogSchema = generateBlogPostingSchema(post, settings)
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ 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">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.featuredImage?.alt || post.title}
|
||||
alt={getMediaAlt(post.featuredImage, post.title)}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { Metadata } from 'next'
|
|||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getPosts } from '@/lib/api'
|
||||
import { formatDate, getImageUrl } from '@/lib/utils'
|
||||
import { SeriesPill } from '@/components/ui'
|
||||
import type { Post, Series } from '@/lib/types'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getMediaUrl, getMediaAlt, resolveRelationArray } from '@/lib/payload-helpers'
|
||||
import type { Post, Category } from '@c2s/payload-contracts/types'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
|
|
@ -20,9 +20,7 @@ export default async function BlogPage() {
|
|||
<section className="py-16 md:py-24">
|
||||
<div className="container text-center">
|
||||
<h1 className="mb-4">Blog</h1>
|
||||
<p className="text-lg text-espresso/80">
|
||||
Noch keine Artikel vorhanden.
|
||||
</p>
|
||||
<p className="text-lg text-espresso/80">Noch keine Artikel vorhanden.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -32,24 +30,17 @@ export default async function BlogPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Hero / Featured Post */}
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="container">
|
||||
<h1 className="text-center mb-12">Blog</h1>
|
||||
|
||||
{/* Featured Post */}
|
||||
<FeaturedPostCard post={featured} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Posts Grid */}
|
||||
<section className="py-12 md:py-16 bg-soft-white">
|
||||
<div className="container">
|
||||
<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">
|
||||
{rest.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
{rest.map((post) => <PostCard key={post.id} post={post} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -58,64 +49,32 @@ export default async function BlogPage() {
|
|||
}
|
||||
|
||||
function FeaturedPostCard({ post }: { post: Post }) {
|
||||
const imageUrl = getImageUrl(post.featuredImage)
|
||||
const series = post.series as Series | undefined
|
||||
const imageUrl = getMediaUrl(post.featuredImage)
|
||||
const categories = resolveRelationArray<Category>(post.categories)
|
||||
|
||||
return (
|
||||
<Link href={`/blog/${post.slug}`} className="group block">
|
||||
<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">
|
||||
{imageUrl && (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.featuredImage?.alt || post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
priority
|
||||
/>
|
||||
<Image src={imageUrl} alt={getMediaAlt(post.featuredImage, post.title)} fill className="object-cover transition-transform duration-500 group-hover:scale-105" priority />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{series && (
|
||||
<SeriesPill series={series.slug}>{series.title}</SeriesPill>
|
||||
{categories[0] && (
|
||||
<span className="text-sm text-brass font-medium">{categories[0].name}</span>
|
||||
)}
|
||||
<time
|
||||
dateTime={post.publishedAt || post.createdAt}
|
||||
className="text-sm text-espresso/60"
|
||||
>
|
||||
<time dateTime={post.publishedAt || post.createdAt} className="text-sm text-espresso/60">
|
||||
{formatDate(post.publishedAt || post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-4 group-hover:text-brass transition-colors">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="text-lg text-espresso/80 line-clamp-3">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-4 group-hover:text-brass transition-colors">{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">
|
||||
Weiterlesen
|
||||
<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="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
|
||||
/>
|
||||
<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="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -125,49 +84,24 @@ function FeaturedPostCard({ post }: { post: Post }) {
|
|||
}
|
||||
|
||||
function PostCard({ post }: { post: Post }) {
|
||||
const imageUrl = getImageUrl(post.featuredImage)
|
||||
const series = post.series as Series | undefined
|
||||
const imageUrl = getMediaUrl(post.featuredImage)
|
||||
const categories = resolveRelationArray<Category>(post.categories)
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Image */}
|
||||
<div className="relative aspect-[16/10] bg-warm-gray">
|
||||
{imageUrl && (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.featuredImage?.alt || post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
{imageUrl && <Image src={imageUrl} alt={getMediaAlt(post.featuredImage, post.title)} fill className="object-cover transition-transform duration-500 group-hover:scale-105" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{series && (
|
||||
<SeriesPill series={series.slug} size="sm">
|
||||
{series.title}
|
||||
</SeriesPill>
|
||||
)}
|
||||
<time
|
||||
dateTime={post.publishedAt || post.createdAt}
|
||||
className="text-xs text-espresso/60"
|
||||
>
|
||||
{categories[0] && <span className="text-xs text-brass font-medium">{categories[0].name}</span>}
|
||||
<time dateTime={post.publishedAt || post.createdAt} className="text-xs text-espresso/60">
|
||||
{formatDate(post.publishedAt || post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-lg mb-2 group-hover:text-brass transition-colors line-clamp-2">
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="text-espresso/80 text-sm line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<h3 className="font-semibold text-lg mb-2 group-hover:text-brass transition-colors line-clamp-2">{post.title}</h3>
|
||||
{post.excerpt && <p className="text-espresso/80 text-sm line-clamp-2">{post.excerpt}</p>}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPage, getSiteSettings } from '@/lib/api'
|
||||
import { getMediaUrl } from '@/lib/payload-helpers'
|
||||
import { BlockRenderer } from '@/components/blocks'
|
||||
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 =
|
||||
page.meta?.description ||
|
||||
page.seo?.metaDescription ||
|
||||
`${settings?.siteName || 'BlogWoman'} - Kommt bald. Sei dabei, wenn es losgeht.`
|
||||
const ogImage = getMediaUrl(page.seo?.ogImage)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -30,7 +32,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
type: 'website',
|
||||
locale: 'de_DE',
|
||||
siteName: settings?.siteName || 'BlogWoman',
|
||||
images: page.meta?.image?.url ? [{ url: page.meta.image.url }] : undefined,
|
||||
images: ogImage ? [{ url: ogImage }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Metadata } from 'next'
|
||||
import Image from 'next/image'
|
||||
import { getFavorites } from '@/lib/api'
|
||||
import { getImageUrl } from '@/lib/utils'
|
||||
import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
|
||||
import { Badge } from '@/components/ui'
|
||||
import type { Favorite } from '@/lib/types'
|
||||
import type { Favorite } from '@c2s/payload-contracts/types'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Favoriten',
|
||||
|
|
@ -21,28 +21,21 @@ const badgeLabels: Record<string, string> = {
|
|||
const categoryLabels: Record<string, string> = {
|
||||
fashion: 'Mode',
|
||||
beauty: 'Beauty',
|
||||
lifestyle: 'Lifestyle',
|
||||
travel: 'Reisen',
|
||||
home: 'Zuhause',
|
||||
tech: 'Tech',
|
||||
books: 'Bücher',
|
||||
}
|
||||
|
||||
export default async function FavoritenPage() {
|
||||
const favoritesData = await getFavorites({ limit: 50 })
|
||||
const favorites = favoritesData.docs
|
||||
|
||||
// Group by category
|
||||
const groupedFavorites = favorites.reduce<Record<string, Favorite[]>>(
|
||||
(acc, fav) => {
|
||||
const category = fav.category || 'other'
|
||||
if (!acc[category]) {
|
||||
acc[category] = []
|
||||
}
|
||||
acc[category].push(fav)
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
const groupedFavorites = favorites.reduce<Record<string, Favorite[]>>((acc, fav) => {
|
||||
const category = fav.category || 'other'
|
||||
if (!acc[category]) acc[category] = []
|
||||
acc[category].push(fav)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const categories = Object.keys(groupedFavorites)
|
||||
|
||||
|
|
@ -51,9 +44,7 @@ export default async function FavoritenPage() {
|
|||
<section className="py-16 md:py-24">
|
||||
<div className="container text-center">
|
||||
<h1 className="mb-4">Favoriten</h1>
|
||||
<p className="text-lg text-espresso/80">
|
||||
Noch keine Favoriten vorhanden.
|
||||
</p>
|
||||
<p className="text-lg text-espresso/80">Noch keine Favoriten vorhanden.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -61,28 +52,21 @@ export default async function FavoritenPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="py-16 md:py-24 bg-soft-white">
|
||||
<div className="container text-center">
|
||||
<h1 className="mb-4">Meine Favoriten</h1>
|
||||
<p className="text-lg text-espresso/80 max-w-2xl mx-auto">
|
||||
Produkte, die ich liebe und guten Gewissens empfehlen kann. Von
|
||||
Fashion-Klassikern bis zu Lifestyle-Essentials.
|
||||
Produkte, die ich liebe und guten Gewissens empfehlen kann. Von Fashion-Klassikern bis zu Lifestyle-Essentials.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Category Navigation */}
|
||||
{categories.length > 1 && (
|
||||
<nav className="sticky top-16 z-30 bg-ivory border-b border-warm-gray py-4">
|
||||
<div className="container">
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 -mb-2 scrollbar-hide">
|
||||
{categories.map((category) => (
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
{categoryLabels[category] || category}
|
||||
</a>
|
||||
))}
|
||||
|
|
@ -91,53 +75,27 @@ export default async function FavoritenPage() {
|
|||
</nav>
|
||||
)}
|
||||
|
||||
{/* Favorites by Category */}
|
||||
{categories.map((category) => (
|
||||
<section
|
||||
key={category}
|
||||
id={category}
|
||||
className="py-16 md:py-20 scroll-mt-32"
|
||||
>
|
||||
<section key={category} id={category} className="py-16 md:py-20 scroll-mt-32">
|
||||
<div className="container">
|
||||
<h2 className="text-2xl font-semibold mb-8">
|
||||
{categoryLabels[category] || category}
|
||||
</h2>
|
||||
|
||||
<h2 className="text-2xl font-semibold mb-8">{categoryLabels[category] || category}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{groupedFavorites[category].map((favorite) => (
|
||||
<FavoriteCard key={favorite.id} favorite={favorite} />
|
||||
))}
|
||||
{groupedFavorites[category].map((favorite) => <FavoriteCard key={favorite.id} favorite={favorite} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Affiliate Disclosure */}
|
||||
<section className="py-12 bg-soft-white">
|
||||
<div className="container">
|
||||
<div className="max-w-2xl mx-auto flex items-start gap-4 p-6 bg-brass/10 rounded-xl">
|
||||
<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"
|
||||
>
|
||||
<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 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">
|
||||
<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>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Hinweis zu Affiliate-Links</h3>
|
||||
<p className="text-espresso/80">
|
||||
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. So kannst du meine Arbeit
|
||||
unterstützen, während du Produkte entdeckst, die ich wirklich
|
||||
liebe.
|
||||
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>
|
||||
|
|
@ -148,86 +106,35 @@ export default async function FavoritenPage() {
|
|||
}
|
||||
|
||||
function FavoriteCard({ favorite }: { favorite: Favorite }) {
|
||||
const imageUrl = getImageUrl(favorite.image)
|
||||
const imageUrl = getMediaUrl(favorite.image)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={favorite.affiliateUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="group block"
|
||||
>
|
||||
<a href={favorite.affiliateUrl} target="_blank" rel="noopener noreferrer sponsored" className="group block">
|
||||
<article className="bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-full flex flex-col">
|
||||
{/* Image */}
|
||||
<div className="relative aspect-square bg-ivory">
|
||||
{imageUrl && (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={favorite.image?.alt || favorite.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Badge */}
|
||||
{imageUrl && <Image src={imageUrl} alt={getMediaAlt(favorite.image, favorite.title)} fill className="object-cover" />}
|
||||
{favorite.badge && (
|
||||
<div className="absolute top-3 left-3">
|
||||
<Badge
|
||||
variant={
|
||||
favorite.badge === 'new'
|
||||
? 'new'
|
||||
: favorite.badge === 'bestseller'
|
||||
? 'popular'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
<Badge variant={favorite.badge === 'new' ? 'new' : favorite.badge === 'bestseller' ? 'popular' : 'default'}>
|
||||
{badgeLabels[favorite.badge]}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 flex-1 flex flex-col">
|
||||
{favorite.category && (
|
||||
<p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2">
|
||||
{categoryLabels[favorite.category] || favorite.category}
|
||||
</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 */}
|
||||
{favorite.category && <p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2">{categoryLabels[favorite.category] || favorite.category}</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>}
|
||||
<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">
|
||||
{favorite.price}
|
||||
{typeof favorite.price === 'number' ? `${favorite.price.toFixed(2)} €` : favorite.price}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-brass group-hover:gap-2 transition-all">
|
||||
Ansehen
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import type { Metadata } from 'next'
|
|||
import { Playfair_Display, Inter } from 'next/font/google'
|
||||
import { Header, Footer } from '@/components/layout'
|
||||
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'
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
|
|
@ -25,10 +26,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
|
||||
const defaultDescription =
|
||||
seoSettings?.metaDefaults?.defaultDescription ||
|
||||
settings?.siteDescription ||
|
||||
'Lifestyle-Blog für moderne Frauen'
|
||||
const defaultImage = seoSettings?.metaDefaults?.defaultImage?.url
|
||||
const canIndex = seoSettings?.robots?.indexing !== false
|
||||
settings?.seo?.defaultMetaDescription ||
|
||||
'Lifestyle-Blog fuer moderne Frauen'
|
||||
const defaultImage = getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage) || getMediaUrl(settings?.seo?.defaultOgImage)
|
||||
const canIndex = seoSettings?.robots?.allowIndexing !== false
|
||||
const verificationOther: Record<string, string> = {}
|
||||
|
||||
if (seoSettings?.verification?.bing) {
|
||||
|
|
@ -71,16 +72,15 @@ export default async function RootLayout({
|
|||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
// Fetch navigation (one doc per tenant) and settings in parallel
|
||||
const [navigation, settings] = await Promise.all([
|
||||
const [navigation, settings, socialLinks] = await Promise.all([
|
||||
getNavigation(),
|
||||
getSiteSettings(),
|
||||
getSocialLinks(),
|
||||
])
|
||||
|
||||
return (
|
||||
<html lang="de" className={`${playfair.variable} ${inter.variable}`}>
|
||||
<body className="font-body text-espresso bg-ivory antialiased">
|
||||
{/* Skip to main content link for accessibility */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-brass focus:text-soft-white focus:rounded-lg focus:outline-none"
|
||||
|
|
@ -90,7 +90,7 @@ export default async function RootLayout({
|
|||
<div className="flex min-h-screen flex-col">
|
||||
<Header mainMenu={navigation?.mainMenu ?? null} settings={settings} />
|
||||
<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>
|
||||
<UmamiScript />
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import Image from 'next/image'
|
|||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
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 { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
|
||||
|
||||
|
|
@ -20,14 +21,14 @@ export async function generateMetadata({ params }: NewsPostPageProps): Promise<M
|
|||
if (!post) return {}
|
||||
|
||||
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 description =
|
||||
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
|
||||
post.seo?.metaDescription || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
|
||||
const image =
|
||||
post.meta?.image?.url ||
|
||||
post.featuredImage?.url ||
|
||||
seoSettings?.metaDefaults?.defaultImage?.url
|
||||
getMediaUrl(post.seo?.ogImage) ||
|
||||
getMediaUrl(post.featuredImage) ||
|
||||
getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -44,10 +45,6 @@ export async function generateMetadata({ params }: NewsPostPageProps): Promise<M
|
|||
description: description || 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()
|
||||
}
|
||||
|
||||
const imageUrl = getImageUrl(post.featuredImage)
|
||||
const imageUrl = getMediaUrl(post.featuredImage)
|
||||
const blogSchema = generateBlogPostingSchema(post, settings)
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ 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">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.featuredImage?.alt || post.title}
|
||||
alt={getMediaAlt(post.featuredImage, post.title)}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Metadata } from 'next'
|
|||
import { getPage, getSeoSettings, getSiteSettings } from '@/lib/api'
|
||||
import { BlockRenderer } from '@/components/blocks'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getMediaUrl } from '@/lib/payload-helpers'
|
||||
import { generateOrganizationSchema, generateWebSiteSchema } from '@/lib/structuredData'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
|
|
@ -15,12 +16,12 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
}
|
||||
|
||||
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 description =
|
||||
page.meta?.description || seoSettings?.metaDefaults?.defaultDescription
|
||||
page.seo?.metaDescription || seoSettings?.metaDefaults?.defaultDescription
|
||||
const image =
|
||||
page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url
|
||||
getMediaUrl(page.seo?.ogImage) || getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -37,10 +38,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
description: description || undefined,
|
||||
images: image ? [image] : undefined,
|
||||
},
|
||||
robots: {
|
||||
index: !page.meta?.noIndex,
|
||||
follow: !page.meta?.noFollow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import Image from 'next/image'
|
|||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
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 { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
|
||||
|
||||
|
|
@ -20,14 +21,14 @@ export async function generateMetadata({ params }: PressPostPageProps): Promise<
|
|||
if (!post) return {}
|
||||
|
||||
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 description =
|
||||
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
|
||||
post.seo?.metaDescription || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
|
||||
const image =
|
||||
post.meta?.image?.url ||
|
||||
post.featuredImage?.url ||
|
||||
seoSettings?.metaDefaults?.defaultImage?.url
|
||||
getMediaUrl(post.seo?.ogImage) ||
|
||||
getMediaUrl(post.featuredImage) ||
|
||||
getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -44,10 +45,6 @@ export async function generateMetadata({ params }: PressPostPageProps): Promise<
|
|||
description: description || 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()
|
||||
}
|
||||
|
||||
const imageUrl = getImageUrl(post.featuredImage)
|
||||
const imageUrl = getMediaUrl(post.featuredImage)
|
||||
const blogSchema = generateBlogPostingSchema(post, settings)
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ 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">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.featuredImage?.alt || post.title}
|
||||
alt={getMediaAlt(post.featuredImage, post.title)}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Metadata } from 'next'
|
|||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSeoSettings, getSeriesBySlug } from '@/lib/api'
|
||||
import { getImageUrl } from '@/lib/utils'
|
||||
import { getMediaUrl } from '@/lib/payload-helpers'
|
||||
import { RichTextRenderer } from '@/components/blocks'
|
||||
import { generateBreadcrumbSchema, generateSeriesSchema } from '@/lib/structuredData'
|
||||
|
||||
|
|
@ -21,16 +21,12 @@ export async function generateMetadata({ params }: SeriesPageProps): Promise<Met
|
|||
if (!series) return {}
|
||||
|
||||
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
|
||||
const titleBase = series.meta?.title || series.title
|
||||
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
|
||||
const description =
|
||||
series.meta?.description ||
|
||||
seoSettings?.metaDefaults?.defaultDescription
|
||||
const title = titleSuffix ? `${series.title} ${titleSuffix}` : series.title
|
||||
const description = seoSettings?.metaDefaults?.defaultDescription || undefined
|
||||
const image =
|
||||
series.meta?.image?.url ||
|
||||
series.coverImage?.url ||
|
||||
series.logo?.url ||
|
||||
seoSettings?.metaDefaults?.defaultImage?.url
|
||||
getMediaUrl(series.coverImage) ||
|
||||
getMediaUrl(series.logo) ||
|
||||
getMediaUrl(seoSettings?.metaDefaults?.defaultOgImage)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -47,10 +43,6 @@ export async function generateMetadata({ params }: SeriesPageProps): Promise<Met
|
|||
description: description || 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()
|
||||
}
|
||||
|
||||
const coverUrl = getImageUrl(series.coverImage) || getImageUrl(series.logo)
|
||||
const coverUrl = getMediaUrl(series.coverImage) || getMediaUrl(series.logo)
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ name: 'Startseite', url: '/' },
|
||||
{ name: 'Serien', url: '/serien' },
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { Metadata } from 'next'
|
|||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { getSeries } from '@/lib/api'
|
||||
import { getImageUrl } from '@/lib/utils'
|
||||
import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
|
||||
import { RichTextRenderer } from '@/components/blocks'
|
||||
import type { Series } from '@/lib/types'
|
||||
import type { Series } from '@c2s/payload-contracts/types'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Serien',
|
||||
|
|
@ -20,9 +20,7 @@ export default async function SerienPage() {
|
|||
<section className="py-16 md:py-24">
|
||||
<div className="container text-center">
|
||||
<h1 className="mb-4">Serien</h1>
|
||||
<p className="text-lg text-espresso/80">
|
||||
Noch keine Serien vorhanden.
|
||||
</p>
|
||||
<p className="text-lg text-espresso/80">Noch keine Serien vorhanden.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -30,24 +28,18 @@ export default async function SerienPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="py-16 md:py-24 bg-soft-white">
|
||||
<div className="container text-center">
|
||||
<h1 className="mb-4">Meine Serien</h1>
|
||||
<p className="text-lg text-espresso/80 max-w-2xl mx-auto">
|
||||
Entdecke meine YouTube-Serien zu verschiedenen Themen rund um
|
||||
Lifestyle, Mode und mehr.
|
||||
Entdecke meine YouTube-Serien zu verschiedenen Themen rund um Lifestyle, Mode und mehr.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Series Grid */}
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{allSeries.map((series) => (
|
||||
<SeriesCard key={series.id} series={series} />
|
||||
))}
|
||||
{allSeries.map((series) => <SeriesCard key={series.id} series={series} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -56,66 +48,27 @@ export default async function SerienPage() {
|
|||
}
|
||||
|
||||
function SeriesCard({ series }: { series: Series }) {
|
||||
const _imageUrl = getImageUrl(series.coverImage) || getImageUrl(series.logo)
|
||||
const coverUrl = getMediaUrl(series.coverImage)
|
||||
const logoUrl = getMediaUrl(series.logo)
|
||||
|
||||
return (
|
||||
<Link href={`/serien/${series.slug}`} className="group block">
|
||||
<article
|
||||
className="relative rounded-2xl overflow-hidden min-h-[320px] flex flex-col justify-end"
|
||||
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 */}
|
||||
<article className="relative rounded-2xl overflow-hidden min-h-[320px] flex flex-col justify-end" style={{ backgroundColor: series.brandColor || '#C6A47E' }}>
|
||||
{coverUrl && <Image src={coverUrl} alt="" fill className="object-cover opacity-40 transition-opacity duration-300 group-hover:opacity-50" />}
|
||||
<div className="relative z-10 p-8">
|
||||
{/* Logo */}
|
||||
{series.logo && (
|
||||
{logoUrl && (
|
||||
<div className="relative w-32 h-14 mb-4">
|
||||
<Image
|
||||
src={series.logo.url}
|
||||
alt=""
|
||||
fill
|
||||
className="object-contain object-left"
|
||||
/>
|
||||
<Image src={logoUrl} alt={getMediaAlt(series.logo)} fill className="object-contain object-left" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-semibold text-soft-white mb-3">
|
||||
{series.title}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
<h2 className="text-2xl font-semibold text-soft-white mb-3">{series.title}</h2>
|
||||
{series.description && (
|
||||
<div className="text-soft-white/80 line-clamp-2 mb-4">
|
||||
<RichTextRenderer content={series.description} />
|
||||
</div>
|
||||
<div className="text-soft-white/80 line-clamp-2 mb-4"><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">
|
||||
Zur Serie
|
||||
<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="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
|
||||
/>
|
||||
<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="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,56 +1,53 @@
|
|||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui'
|
||||
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({
|
||||
heading,
|
||||
subheading,
|
||||
buttonText,
|
||||
buttonLink,
|
||||
backgroundColor = 'brass',
|
||||
backgroundImage,
|
||||
headline,
|
||||
description,
|
||||
buttons,
|
||||
backgroundColor = 'dark',
|
||||
}: CTABlockProps) {
|
||||
const bgClasses = {
|
||||
brass: 'bg-brass',
|
||||
espresso: 'bg-espresso',
|
||||
bordeaux: 'bg-bordeaux',
|
||||
const bgClasses: Record<string, string> = {
|
||||
dark: 'bg-espresso',
|
||||
light: 'bg-ivory',
|
||||
accent: 'bg-brass',
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
const firstButton = buttons?.[0]
|
||||
|
||||
return (
|
||||
<section className={cn('relative py-16 md:py-24', bgClasses[backgroundColor || 'dark'])}>
|
||||
<div className="container relative z-10">
|
||||
<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 && (
|
||||
<p className="text-soft-white/80 text-lg md:text-xl mb-8">
|
||||
{subheading}
|
||||
{description && (
|
||||
<p className={cn(
|
||||
'text-lg md:text-xl mb-8',
|
||||
backgroundColor === 'light' ? 'text-espresso/80' : 'text-soft-white/80'
|
||||
)}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
href={buttonLink}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
{firstButton && (
|
||||
<Button
|
||||
href={firstButton.link}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className={cn(
|
||||
backgroundColor !== 'light' &&
|
||||
'border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso'
|
||||
)}
|
||||
>
|
||||
{firstButton.text}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,68 +1,63 @@
|
|||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
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({
|
||||
title,
|
||||
subtitle,
|
||||
headline,
|
||||
cards,
|
||||
columns = 3,
|
||||
columns = '3',
|
||||
}: CardGridBlockProps) {
|
||||
const columnClasses = {
|
||||
2: 'md:grid-cols-2',
|
||||
3: 'md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'md:grid-cols-2 lg:grid-cols-4',
|
||||
const columnClasses: Record<string, string> = {
|
||||
'2': 'md:grid-cols-2',
|
||||
'3': 'md:grid-cols-2 lg:grid-cols-3',
|
||||
'4': 'md:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="container">
|
||||
{/* Section Header */}
|
||||
{(title || subtitle) && (
|
||||
{headline && (
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
{title && <h2 className="mb-4">{title}</h2>}
|
||||
{subtitle && (
|
||||
<p className="text-lg text-espresso/80">{subtitle}</p>
|
||||
)}
|
||||
<h2 className="mb-4">{headline}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Grid */}
|
||||
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>
|
||||
{cards.map((card, index) => (
|
||||
<CardItem key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
{cards && cards.length > 0 && (
|
||||
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns || '3'])}>
|
||||
{cards.map((card, index) => (
|
||||
<CardItem key={card.id || index} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardItemProps {
|
||||
title: string
|
||||
description?: string
|
||||
image?: { url: string; alt?: string }
|
||||
link?: string
|
||||
icon?: string
|
||||
}
|
||||
type CardData = NonNullable<CardGridBlockProps['cards']>[number]
|
||||
|
||||
function CardItem({ card }: { card: CardData }) {
|
||||
const imgUrl = getMediaUrl(card.image)
|
||||
|
||||
function CardItem({ title, description, image, link, icon }: CardItemProps) {
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-soft-white border border-warm-gray rounded-2xl overflow-hidden',
|
||||
'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">
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={image.alt || title}
|
||||
src={imgUrl}
|
||||
alt={getMediaAlt(card.image, card.title)}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
|
|
@ -70,24 +65,24 @@ function CardItem({ title, description, image, link, icon }: CardItemProps) {
|
|||
)}
|
||||
|
||||
<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">
|
||||
<span className="text-2xl">{icon}</span>
|
||||
<span className="text-2xl">{card.icon}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{title}</h3>
|
||||
<h3 className="text-xl font-semibold mb-2">{card.title}</h3>
|
||||
|
||||
{description && (
|
||||
<p className="text-espresso/80">{description}</p>
|
||||
{card.description && (
|
||||
<p className="text-espresso/80">{card.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (link) {
|
||||
if (card.link) {
|
||||
return (
|
||||
<Link href={link} className="block">
|
||||
<Link href={card.link} className="block">
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, Input, Textarea } from '@/components/ui'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
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({
|
||||
title,
|
||||
subtitle,
|
||||
formId,
|
||||
showName = true,
|
||||
headline,
|
||||
description,
|
||||
form: formRef,
|
||||
showPhone = false,
|
||||
showSubject = true,
|
||||
successMessage = 'Vielen Dank für Ihre Nachricht! Wir melden uns zeitnah bei Ihnen.',
|
||||
}: ContactFormBlockProps) {
|
||||
const formId = typeof formRef === 'object' && formRef !== null ? formRef.id : (formRef as number | undefined)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
|
|
@ -26,26 +26,16 @@ export function ContactFormBlock({
|
|||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
function handleChange(
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}))
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setStatus('loading')
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const result = await submitContactForm({
|
||||
...formData,
|
||||
formId,
|
||||
})
|
||||
|
||||
const result = await submitContactForm({ ...formData, formId })
|
||||
if (result.success) {
|
||||
setStatus('success')
|
||||
setFormData({ name: '', email: '', phone: '', subject: '', message: '' })
|
||||
|
|
@ -63,111 +53,36 @@ export function ContactFormBlock({
|
|||
<section className="py-16 md:py-20">
|
||||
<div className="container">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
{(title || subtitle) && (
|
||||
{(headline || description) && (
|
||||
<div className="text-center mb-10">
|
||||
{title && <h2 className="mb-4">{title}</h2>}
|
||||
{subtitle && (
|
||||
<p className="text-lg text-espresso/80">{subtitle}</p>
|
||||
)}
|
||||
{headline && <h2 className="mb-4">{headline}</h2>}
|
||||
{description && <p className="text-lg text-espresso/80">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{status === 'success' ? (
|
||||
<div className="p-6 bg-success/10 text-success rounded-xl text-center">
|
||||
<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"
|
||||
>
|
||||
<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 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">
|
||||
<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>
|
||||
<p className="font-medium text-lg">{successMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name & Email Row */}
|
||||
<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
|
||||
label="E-Mail"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
<Input 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>
|
||||
|
||||
{/* Phone & Subject Row */}
|
||||
{(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>
|
||||
{showPhone && (
|
||||
<Input label="Telefon" name="phone" type="tel" value={formData.phone} onChange={handleChange} disabled={status === 'loading'} />
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
<Textarea
|
||||
label="Nachricht"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
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 */}
|
||||
<Input label="Betreff" name="subject" value={formData.subject} onChange={handleChange} required disabled={status === 'loading'} />
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-espresso mb-2">Nachricht</label>
|
||||
<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" />
|
||||
</div>
|
||||
{status === 'error' && <div className="p-4 bg-error/10 text-error rounded-lg">{errorMessage}</div>}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={status === 'loading'}
|
||||
>
|
||||
<Button type="submit" size="lg" disabled={status === 'loading'}>
|
||||
{status === 'loading' ? 'Wird gesendet...' : 'Nachricht senden'}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
return <div className="py-12" />
|
||||
return <div className={py} />
|
||||
}
|
||||
|
||||
if (style === 'dots') {
|
||||
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" />
|
||||
|
|
@ -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 (
|
||||
<div className="py-12 container">
|
||||
<div className={`${py} container`}>
|
||||
<div className="h-px bg-warm-gray" />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,31 +3,32 @@
|
|||
import { useState } from 'react'
|
||||
import Script from 'next/script'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveRelationArray } from '@/lib/payload-helpers'
|
||||
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'> & {
|
||||
faqs?: FAQ[]
|
||||
type FAQBlockProps = Omit<BlockByType<'faq-block'>, 'blockType' | 'blockName'> & {
|
||||
faqs?: Faq[]
|
||||
}
|
||||
|
||||
export function FAQBlock({
|
||||
title,
|
||||
subtitle,
|
||||
displayMode,
|
||||
selectedFaqs,
|
||||
filterCategory: _filterCategory,
|
||||
selectedFAQs,
|
||||
category: _category,
|
||||
layout = 'accordion',
|
||||
expandFirst = false,
|
||||
showSchema = true,
|
||||
enableSchemaOrg = true,
|
||||
faqs: externalFaqs,
|
||||
}: FAQBlockProps) {
|
||||
// Use selectedFaqs if displayMode is 'selected', otherwise use externalFaqs
|
||||
const items = displayMode === 'selected' ? selectedFaqs : externalFaqs
|
||||
const items = displayMode === 'selected'
|
||||
? resolveRelationArray<Faq>(selectedFAQs)
|
||||
: externalFaqs || []
|
||||
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
// Generate JSON-LD schema data
|
||||
const schemaData = showSchema
|
||||
const schemaData = enableSchemaOrg
|
||||
? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
|
|
@ -45,34 +46,23 @@ export function FAQBlock({
|
|||
return (
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="container">
|
||||
{/* Section Header */}
|
||||
{(title || subtitle) && (
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
{title && <h2 className="mb-4">{title}</h2>}
|
||||
{subtitle && (
|
||||
<p className="text-lg text-espresso/80">{subtitle}</p>
|
||||
)}
|
||||
{subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAQ Items */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{layout === 'accordion' ? (
|
||||
<AccordionFAQ items={items} expandFirst={expandFirst} />
|
||||
<AccordionFAQ items={items} expandFirst={expandFirst ?? false} />
|
||||
) : layout === 'grid' ? (
|
||||
<GridFAQ items={items} />
|
||||
) : (
|
||||
<ListFAQ items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JSON-LD Schema using Next.js Script component for safety */}
|
||||
{schemaData && (
|
||||
<Script
|
||||
id="faq-schema"
|
||||
type="application/ld+json"
|
||||
strategy="afterInteractive"
|
||||
>
|
||||
<Script id="faq-schema" type="application/ld+json" strategy="afterInteractive">
|
||||
{JSON.stringify(schemaData)}
|
||||
</Script>
|
||||
)}
|
||||
|
|
@ -81,60 +71,25 @@ export function FAQBlock({
|
|||
)
|
||||
}
|
||||
|
||||
function AccordionFAQ({
|
||||
items,
|
||||
expandFirst,
|
||||
}: {
|
||||
items: FAQ[]
|
||||
expandFirst: boolean
|
||||
}) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(
|
||||
expandFirst ? 0 : null
|
||||
)
|
||||
function AccordionFAQ({ items, expandFirst }: { items: Faq[]; expandFirst: boolean }) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(expandFirst ? 0 : null)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((faq, index) => (
|
||||
<div
|
||||
key={faq.id}
|
||||
className="border border-warm-gray rounded-xl overflow-hidden"
|
||||
>
|
||||
<div key={faq.id} className="border border-warm-gray rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left bg-soft-white hover:bg-ivory transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-inset"
|
||||
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)}
|
||||
aria-expanded={openIndex === index}
|
||||
>
|
||||
<span className="font-headline text-lg font-medium text-espresso pr-4">
|
||||
{faq.question}
|
||||
</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className={cn(
|
||||
'w-5 h-5 text-brass transition-transform duration-200 flex-shrink-0',
|
||||
openIndex === index && 'rotate-180'
|
||||
)}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
<span className="font-headline text-lg font-medium text-espresso pr-4">{faq.question}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className={cn('w-5 h-5 text-brass transition-transform duration-200 flex-shrink-0', openIndex === index && 'rotate-180')}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
openIndex === index
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className={cn('grid transition-all duration-300 ease-out', openIndex === index ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
<RichTextRenderer content={faq.answer} />
|
||||
|
|
@ -147,7 +102,7 @@ function AccordionFAQ({
|
|||
)
|
||||
}
|
||||
|
||||
function ListFAQ({ items }: { items: FAQ[] }) {
|
||||
function ListFAQ({ items }: { items: Faq[] }) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{items.map((faq) => (
|
||||
|
|
@ -160,14 +115,11 @@ function ListFAQ({ items }: { items: FAQ[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
function GridFAQ({ items }: { items: FAQ[] }) {
|
||||
function GridFAQ({ items }: { items: Faq[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{items.map((faq) => (
|
||||
<div
|
||||
key={faq.id}
|
||||
className="bg-soft-white border border-warm-gray rounded-xl p-6"
|
||||
>
|
||||
<div key={faq.id} className="bg-soft-white border border-warm-gray rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{faq.question}</h3>
|
||||
<RichTextRenderer content={faq.answer} />
|
||||
</div>
|
||||
|
|
@ -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 ''
|
||||
|
||||
function extractFromNode(node: Record<string, unknown>): string {
|
||||
if (node.text) return node.text as string
|
||||
|
||||
const children = node.children as Record<string, unknown>[] | undefined
|
||||
if (children) {
|
||||
return children.map(extractFromNode).join('')
|
||||
}
|
||||
|
||||
if (children) return children.map(extractFromNode).join('')
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,34 @@
|
|||
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 { 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({
|
||||
title,
|
||||
subtitle,
|
||||
displayMode,
|
||||
selectedFavorites,
|
||||
filterCategory,
|
||||
category,
|
||||
layout: _layout = 'grid',
|
||||
columns = 3,
|
||||
columns = '3',
|
||||
limit = 12,
|
||||
showPrice = true,
|
||||
showBadge = true,
|
||||
}: FavoritesBlockProps) {
|
||||
// Fetch favorites if not using selected mode
|
||||
let items: Favorite[] = []
|
||||
|
||||
if (displayMode === 'selected' && selectedFavorites) {
|
||||
items = selectedFavorites
|
||||
} else {
|
||||
const favoritesData = await getFavorites({
|
||||
category: filterCategory,
|
||||
limit,
|
||||
})
|
||||
items = favoritesData.docs
|
||||
}
|
||||
const favoritesData = await getFavorites({
|
||||
category: category && category !== 'all' ? category : undefined,
|
||||
limit: limit ?? 12,
|
||||
})
|
||||
const items = favoritesData.docs
|
||||
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
const columnClasses = {
|
||||
2: 'md:grid-cols-2',
|
||||
3: 'md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'md:grid-cols-2 lg:grid-cols-4',
|
||||
const columnClasses: Record<string, string> = {
|
||||
'2': 'md:grid-cols-2',
|
||||
'3': 'md:grid-cols-2 lg:grid-cols-3',
|
||||
'4': 'md:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
|
||||
const badgeLabels: Record<string, string> = {
|
||||
|
|
@ -58,136 +50,57 @@ export async function FavoritesBlock({
|
|||
return (
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="container">
|
||||
{/* Section Header */}
|
||||
{(title || subtitle) && (
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
{title && <h2 className="mb-4">{title}</h2>}
|
||||
{subtitle && (
|
||||
<p className="text-lg text-espresso/80">{subtitle}</p>
|
||||
)}
|
||||
{subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorites Grid */}
|
||||
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>
|
||||
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns || '3'])}>
|
||||
{items.map((favorite) => (
|
||||
<FavoriteCard
|
||||
key={favorite.id}
|
||||
favorite={favorite}
|
||||
showPrice={showPrice}
|
||||
showBadge={showBadge}
|
||||
badgeLabels={badgeLabels}
|
||||
badgeVariants={badgeVariants}
|
||||
/>
|
||||
<FavoriteCard key={favorite.id} favorite={favorite} showPrice={showPrice} showBadge={showBadge} badgeLabels={badgeLabels} badgeVariants={badgeVariants} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Affiliate Disclosure */}
|
||||
<div className="mt-12 flex items-start gap-3 p-4 bg-brass/10 rounded-lg max-w-2xl mx-auto">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5 text-brass flex-shrink-0 mt-0.5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 text-brass flex-shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<p className="text-sm text-espresso">
|
||||
Einige Links auf dieser Seite sind Affiliate-Links. Das bedeutet, dass ich eine kleine Provision erhalte, wenn du über diese Links einkaufst - ohne Mehrkosten für dich.
|
||||
</p>
|
||||
<p className="text-sm text-espresso">Einige Links auf dieser Seite sind Affiliate-Links. Das bedeutet, dass ich eine kleine Provision erhalte, wenn du über diese Links einkaufst - ohne Mehrkosten für dich.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface FavoriteCardProps {
|
||||
favorite: Favorite
|
||||
showPrice?: boolean
|
||||
showBadge?: boolean
|
||||
badgeLabels: Record<string, string>
|
||||
badgeVariants: Record<string, 'new' | 'popular' | 'investment' | 'daily' | 'grfi' | 'default'>
|
||||
}
|
||||
|
||||
function FavoriteCard({
|
||||
favorite,
|
||||
showPrice,
|
||||
showBadge,
|
||||
badgeLabels,
|
||||
badgeVariants,
|
||||
}: FavoriteCardProps) {
|
||||
const imageUrl = getImageUrl(favorite.image)
|
||||
function FavoriteCard({ favorite, showPrice, showBadge, badgeLabels, badgeVariants }: { favorite: Favorite; showPrice?: boolean | null; showBadge?: boolean | null; badgeLabels: Record<string, string>; badgeVariants: Record<string, string> }) {
|
||||
const imageUrl = getMediaUrl(favorite.image)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={favorite.affiliateUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="group block"
|
||||
>
|
||||
<a href={favorite.affiliateUrl} target="_blank" rel="noopener noreferrer sponsored" className="group block">
|
||||
<article className="bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl">
|
||||
{/* Image */}
|
||||
<div className="relative aspect-square bg-ivory">
|
||||
{imageUrl && (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={favorite.image?.alt || favorite.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Badge */}
|
||||
{imageUrl && <Image src={imageUrl} alt={getMediaAlt(favorite.image, favorite.title)} fill className="object-cover" />}
|
||||
{showBadge && favorite.badge && (
|
||||
<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]}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
{favorite.category && (
|
||||
<p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2">
|
||||
{favorite.category}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h3 className="font-headline text-lg font-semibold text-espresso mb-2 group-hover:text-brass transition-colors">
|
||||
{favorite.title}
|
||||
</h3>
|
||||
|
||||
{/* Footer */}
|
||||
{favorite.category && <p className="text-[11px] font-semibold tracking-[0.08em] uppercase text-sand mb-2">{favorite.category}</p>}
|
||||
<h3 className="font-headline text-lg font-semibold text-espresso mb-2 group-hover:text-brass transition-colors">{favorite.title}</h3>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
{showPrice && favorite.price && (
|
||||
{showPrice && favorite.price != null && (
|
||||
<span className="font-semibold text-espresso">
|
||||
{favorite.price}
|
||||
{typeof favorite.price === 'number' ? `${favorite.price.toFixed(2)} €` : favorite.price}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-brass group-hover:gap-2 transition-all">
|
||||
Ansehen
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui'
|
||||
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({
|
||||
heading,
|
||||
subheading,
|
||||
headline,
|
||||
subline,
|
||||
backgroundImage,
|
||||
ctaText,
|
||||
ctaLink,
|
||||
cta,
|
||||
alignment = 'center',
|
||||
overlay = true,
|
||||
overlayOpacity = 50,
|
||||
}: HeroBlockProps) {
|
||||
const bgUrl = getMediaUrl(backgroundImage)
|
||||
|
||||
const alignmentClasses = {
|
||||
left: 'text-left items-start',
|
||||
center: 'text-center items-center',
|
||||
|
|
@ -24,20 +25,17 @@ export function HeroBlock({
|
|||
return (
|
||||
<section className="relative min-h-[60vh] md:min-h-[70vh] flex items-center">
|
||||
{/* Background Image */}
|
||||
{backgroundImage?.url && (
|
||||
{bgUrl && (
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={backgroundImage.url}
|
||||
alt={backgroundImage.alt || ''}
|
||||
src={bgUrl}
|
||||
alt={getMediaAlt(backgroundImage)}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
{overlay && (
|
||||
<div
|
||||
className="absolute inset-0 bg-espresso"
|
||||
style={{ opacity: overlayOpacity / 100 }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-espresso/50" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -47,33 +45,33 @@ export function HeroBlock({
|
|||
<div
|
||||
className={cn(
|
||||
'flex flex-col max-w-3xl py-20',
|
||||
alignmentClasses[alignment],
|
||||
alignment === 'center' && 'mx-auto'
|
||||
alignmentClasses[alignment || 'center'],
|
||||
(alignment || 'center') === 'center' && 'mx-auto'
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
className={cn(
|
||||
'mb-6',
|
||||
backgroundImage ? 'text-soft-white' : 'text-espresso'
|
||||
bgUrl ? 'text-soft-white' : 'text-espresso'
|
||||
)}
|
||||
>
|
||||
{heading}
|
||||
{headline}
|
||||
</h1>
|
||||
|
||||
{subheading && (
|
||||
{subline && (
|
||||
<p
|
||||
className={cn(
|
||||
'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>
|
||||
)}
|
||||
|
||||
{ctaText && ctaLink && (
|
||||
<Button href={ctaLink} size="lg">
|
||||
{ctaText}
|
||||
{cta?.text && cta?.link && (
|
||||
<Button href={cta.link} size="lg">
|
||||
{cta.text}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,22 @@
|
|||
import Image from 'next/image'
|
||||
import { cn } from '@/lib/utils'
|
||||
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({
|
||||
heading,
|
||||
headline,
|
||||
content,
|
||||
image,
|
||||
imagePosition = 'left',
|
||||
backgroundColor = 'white',
|
||||
cta,
|
||||
}: ImageTextBlockProps) {
|
||||
const bgClasses = {
|
||||
white: 'bg-soft-white',
|
||||
ivory: 'bg-ivory',
|
||||
sand: 'bg-sand/20',
|
||||
}
|
||||
const imgUrl = getMediaUrl(image)
|
||||
|
||||
return (
|
||||
<section className={cn('py-16 md:py-20', bgClasses[backgroundColor])}>
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="container">
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -28,21 +25,31 @@ export function ImageTextBlock({
|
|||
)}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className={cn('md:[direction:ltr]')}>
|
||||
<div className="md:[direction:ltr]">
|
||||
<div className="relative aspect-[4/3] rounded-2xl overflow-hidden">
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={image.alt || ''}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
{imgUrl && (
|
||||
<Image
|
||||
src={imgUrl}
|
||||
alt={getMediaAlt(image)}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="md:[direction:ltr]">
|
||||
{heading && <h2 className="mb-6">{heading}</h2>}
|
||||
{headline && <h2 className="mb-6">{headline}</h2>}
|
||||
<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} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,38 +5,37 @@ import Image from 'next/image'
|
|||
import Link from 'next/link'
|
||||
import { Button, Input } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getMediaUrl } from '@/lib/payload-helpers'
|
||||
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({
|
||||
title = 'Newsletter',
|
||||
subtitle,
|
||||
buttonText = 'Anmelden',
|
||||
layout = 'card',
|
||||
backgroundImage,
|
||||
showPrivacyNote = true,
|
||||
image,
|
||||
collectName = false,
|
||||
source = 'newsletter-block',
|
||||
showFirstName = false,
|
||||
}: NewsletterBlockProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const bgImageUrl = getMediaUrl(image)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setStatus('loading')
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const result = await subscribeNewsletter(
|
||||
email,
|
||||
showFirstName && firstName ? firstName : undefined,
|
||||
source
|
||||
collectName && firstName ? firstName : undefined,
|
||||
source || 'newsletter-block'
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
setStatus('success')
|
||||
setEmail('')
|
||||
|
|
@ -58,115 +57,44 @@ export function NewsletterBlock({
|
|||
<p className="text-sm mt-1">Bitte bestätigen Sie Ihre E-Mail-Adresse.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className={cn(
|
||||
'flex flex-col gap-4',
|
||||
(layout === 'inline' || layout === 'minimal') && !showFirstName && 'sm:flex-row'
|
||||
)}>
|
||||
{showFirstName && (
|
||||
<Input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="Dein Vorname (optional)"
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className={cn('flex flex-col gap-4', (layout === 'inline' || layout === 'minimal') && !collectName && 'sm:flex-row')}>
|
||||
{collectName && (
|
||||
<Input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Dein Vorname (optional)" disabled={status === 'loading'} />
|
||||
)}
|
||||
<div className={cn(
|
||||
'flex flex-col gap-4',
|
||||
(layout === 'inline' || layout === 'minimal') && 'sm:flex-row'
|
||||
)}>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
error={status === 'error' ? errorMessage : undefined}
|
||||
className={cn(
|
||||
(layout === 'inline' || layout === 'minimal') && 'sm:flex-1'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{status === 'loading' ? 'Wird gesendet...' : buttonText}
|
||||
<div className={cn('flex flex-col gap-4', (layout === 'inline' || layout === 'minimal') && 'sm:flex-row')}>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Deine E-Mail-Adresse" required disabled={status === 'loading'} error={status === 'error' ? errorMessage : undefined} className={cn((layout === 'inline' || layout === 'minimal') && 'sm:flex-1')} />
|
||||
<Button type="submit" disabled={status === 'loading'} className="whitespace-nowrap">
|
||||
{status === 'loading' ? 'Wird gesendet...' : buttonText || 'Anmelden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showPrivacyNote && status !== 'success' && (
|
||||
{status !== 'success' && (
|
||||
<p className="text-sm text-warm-gray-dark mt-4 text-center">
|
||||
Mit der Anmeldung akzeptieren Sie unsere{' '}
|
||||
<Link href="/datenschutz" className="underline hover:text-espresso">
|
||||
Datenschutzerklärung
|
||||
</Link>
|
||||
.
|
||||
<Link href="/datenschutz" className="underline hover:text-espresso">Datenschutzerklärung</Link>.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
// Minimal layout
|
||||
if (layout === 'minimal') {
|
||||
return (
|
||||
<section className="py-8">
|
||||
<div className="container max-w-xl">
|
||||
{formContent}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
return <section className="py-8"><div className="container max-w-xl">{formContent}</div></section>
|
||||
}
|
||||
|
||||
// Card layout (default)
|
||||
return (
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="container">
|
||||
<div
|
||||
className={cn(
|
||||
'relative bg-soft-white border border-warm-gray rounded-2xl p-8 md:p-10 overflow-hidden',
|
||||
backgroundImage && 'text-soft-white'
|
||||
)}
|
||||
>
|
||||
{/* Background Image */}
|
||||
{backgroundImage?.url && (
|
||||
<div className={cn('relative bg-soft-white border border-warm-gray rounded-2xl p-8 md:p-10 overflow-hidden', bgImageUrl && 'text-soft-white')}>
|
||||
{bgImageUrl && (
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={backgroundImage.url}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<Image src={bgImageUrl} alt="" fill className="object-cover" />
|
||||
<div className="absolute inset-0 bg-espresso/60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 max-w-xl mx-auto text-center">
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
'mb-3',
|
||||
backgroundImage && 'text-soft-white'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-lg mb-6',
|
||||
backgroundImage ? 'text-soft-white/80' : 'text-espresso/80'
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{title && <h2 className={cn('mb-3', bgImageUrl && 'text-soft-white')}>{title}</h2>}
|
||||
{subtitle && <p className={cn('text-lg mb-6', bgImageUrl ? 'text-soft-white/80' : 'text-espresso/80')}>{subtitle}</p>}
|
||||
{formContent}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
import Image from 'next/image'
|
||||
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 { 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
|
||||
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'>
|
||||
type PostsListBlockProps = Omit<BlockByType<'posts-list-block'>, 'blockType' | 'blockName'>
|
||||
|
||||
export async function PostsListBlock({
|
||||
title,
|
||||
subtitle,
|
||||
postType,
|
||||
layout = 'grid',
|
||||
columns = 3,
|
||||
columns = '3',
|
||||
limit = 6,
|
||||
showFeaturedOnly,
|
||||
filterByCategory,
|
||||
|
|
@ -28,13 +22,18 @@ export async function PostsListBlock({
|
|||
showAuthor = false,
|
||||
showCategory = true,
|
||||
showPagination = false,
|
||||
backgroundColor = 'ivory',
|
||||
backgroundColor = 'white',
|
||||
}: PostsListBlockProps) {
|
||||
// filterByCategory is (number | Category)[] in contracts
|
||||
const categorySlug = filterByCategory
|
||||
? resolveRelationArray<Category>(filterByCategory)[0]?.slug
|
||||
: undefined
|
||||
|
||||
const postsData = await getPosts({
|
||||
type: postType,
|
||||
category: filterByCategory,
|
||||
limit,
|
||||
featured: showFeaturedOnly,
|
||||
type: postType === 'all' ? undefined : postType,
|
||||
category: categorySlug,
|
||||
limit: limit ?? 6,
|
||||
featured: showFeaturedOnly ?? undefined,
|
||||
})
|
||||
|
||||
const posts = postsData.docs
|
||||
|
|
@ -46,9 +45,7 @@ export async function PostsListBlock({
|
|||
{(title || subtitle) && (
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
{title && <h2 className="mb-4">{title}</h2>}
|
||||
{subtitle && (
|
||||
<p className="text-lg text-espresso/80">{subtitle}</p>
|
||||
)}
|
||||
{subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<EmptyState
|
||||
|
|
@ -62,68 +59,44 @@ export async function PostsListBlock({
|
|||
)
|
||||
}
|
||||
|
||||
const bgClasses = {
|
||||
const bgClasses: Record<string, string> = {
|
||||
white: 'bg-soft-white',
|
||||
ivory: 'bg-ivory',
|
||||
sand: 'bg-sand/20',
|
||||
light: 'bg-ivory',
|
||||
dark: 'bg-espresso text-soft-white',
|
||||
}
|
||||
|
||||
const columnClasses = {
|
||||
2: 'md:grid-cols-2',
|
||||
3: 'md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'md:grid-cols-2 lg:grid-cols-4',
|
||||
const columnClasses: Record<string, string> = {
|
||||
'2': 'md:grid-cols-2',
|
||||
'3': 'md:grid-cols-2 lg:grid-cols-3',
|
||||
'4': 'md:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={cn('py-16 md:py-20', bgClasses[backgroundColor])}>
|
||||
<section className={cn('py-16 md:py-20', bgClasses[backgroundColor || 'white'])}>
|
||||
<div className="container">
|
||||
{/* Section Header */}
|
||||
{(title || subtitle) && (
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
{title && <h2 className="mb-4">{title}</h2>}
|
||||
{subtitle && (
|
||||
<p className="text-lg text-espresso/80">{subtitle}</p>
|
||||
)}
|
||||
{subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts */}
|
||||
{layout === 'grid' ? (
|
||||
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>
|
||||
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns || '3'])}>
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
showExcerpt={showExcerpt}
|
||||
showDate={showDate}
|
||||
showAuthor={showAuthor}
|
||||
showCategory={showCategory}
|
||||
/>
|
||||
<PostCard key={post.id} post={post} showExcerpt={showExcerpt} showDate={showDate} showAuthor={showAuthor} showCategory={showCategory} />
|
||||
))}
|
||||
</div>
|
||||
) : layout === 'featured' ? (
|
||||
<FeaturedLayout
|
||||
posts={posts}
|
||||
showExcerpt={showExcerpt}
|
||||
showDate={showDate}
|
||||
showCategory={showCategory}
|
||||
/>
|
||||
<FeaturedLayout posts={posts} showExcerpt={showExcerpt} showDate={showDate} showCategory={showCategory} />
|
||||
) : layout === 'list' ? (
|
||||
<ListLayout
|
||||
posts={posts}
|
||||
showExcerpt={showExcerpt}
|
||||
showDate={showDate}
|
||||
showCategory={showCategory}
|
||||
/>
|
||||
<ListLayout posts={posts} showExcerpt={showExcerpt} showDate={showDate} showCategory={showCategory} />
|
||||
) : (
|
||||
<CompactLayout posts={posts} showDate={showDate} />
|
||||
)}
|
||||
|
||||
{/* Pagination would go here */}
|
||||
{showPagination && postsData.totalPages > 1 && (
|
||||
<div className="mt-12 text-center">
|
||||
{/* Implement pagination component */}
|
||||
</div>
|
||||
<div className="mt-12 text-center" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -132,21 +105,17 @@ export async function PostsListBlock({
|
|||
|
||||
interface PostCardProps {
|
||||
post: Post
|
||||
showExcerpt?: boolean
|
||||
showDate?: boolean
|
||||
showAuthor?: boolean
|
||||
showCategory?: boolean
|
||||
showExcerpt?: boolean | null
|
||||
showDate?: boolean | null
|
||||
showAuthor?: boolean | null
|
||||
showCategory?: boolean | null
|
||||
}
|
||||
|
||||
function PostCard({
|
||||
post,
|
||||
showExcerpt,
|
||||
showDate,
|
||||
showAuthor,
|
||||
showCategory,
|
||||
}: PostCardProps) {
|
||||
const imageUrl = getImageUrl(post.featuredImage)
|
||||
function PostCard({ post, showExcerpt, showDate, showAuthor, showCategory }: PostCardProps) {
|
||||
const imageUrl = getMediaUrl(post.featuredImage)
|
||||
const postUrl = getPostUrl(post)
|
||||
const categories = resolveRelationArray<Category>(post.categories)
|
||||
const authorName = getAuthorName(post.author)
|
||||
|
||||
return (
|
||||
<Link href={postUrl} className="group block">
|
||||
|
|
@ -155,205 +124,114 @@ function PostCard({
|
|||
<div className="relative aspect-video overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.featuredImage?.alt || post.title}
|
||||
alt={getMediaAlt(post.featuredImage, post.title)}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6">
|
||||
{/* Meta */}
|
||||
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-3">
|
||||
{showCategory && post.categories?.[0] && (
|
||||
<span className="text-brass font-medium">
|
||||
{post.categories[0].title}
|
||||
</span>
|
||||
)}
|
||||
{showDate && post.publishedAt && (
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
{showCategory && categories[0] && (
|
||||
<span className="text-brass font-medium">{categories[0].name}</span>
|
||||
)}
|
||||
{showDate && post.publishedAt && <span>{formatDate(post.publishedAt)}</span>}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-brass transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
{/* Excerpt */}
|
||||
{showExcerpt && post.excerpt && (
|
||||
<p className="text-espresso/80 line-clamp-2">{post.excerpt}</p>
|
||||
)}
|
||||
|
||||
{/* Author */}
|
||||
{showAuthor && getAuthorName(post.author) && (
|
||||
<p className="mt-4 text-sm text-warm-gray-dark">
|
||||
von {getAuthorName(post.author)}
|
||||
</p>
|
||||
)}
|
||||
<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>}
|
||||
{showAuthor && authorName && <p className="mt-4 text-sm text-warm-gray-dark">von {authorName}</p>}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturedLayout({
|
||||
posts,
|
||||
showExcerpt,
|
||||
showDate,
|
||||
showCategory,
|
||||
}: {
|
||||
posts: Post[]
|
||||
showExcerpt?: boolean
|
||||
showDate?: boolean
|
||||
showCategory?: boolean
|
||||
}) {
|
||||
function FeaturedLayout({ posts, showExcerpt, showDate, showCategory }: { posts: Post[]; showExcerpt?: boolean | null; showDate?: boolean | null; showCategory?: boolean | null }) {
|
||||
const [featured, ...rest] = posts
|
||||
const imageUrl = getMediaUrl(featured.featuredImage)
|
||||
const categories = resolveRelationArray<Category>(featured.categories)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Featured Post */}
|
||||
<Link href={getPostUrl(featured)} className="group block lg:row-span-2">
|
||||
<article className="h-full bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-xl">
|
||||
{featured.featuredImage && (
|
||||
{imageUrl && (
|
||||
<div className="relative aspect-[4/3] lg:aspect-[16/10] overflow-hidden">
|
||||
<Image
|
||||
src={featured.featuredImage.url}
|
||||
alt={featured.featuredImage.alt || featured.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<Image src={imageUrl} alt={getMediaAlt(featured.featuredImage, featured.title)} fill className="object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 lg:p-8">
|
||||
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-3">
|
||||
{showCategory && featured.categories?.[0] && (
|
||||
<span className="text-brass font-medium">
|
||||
{featured.categories[0].title}
|
||||
</span>
|
||||
)}
|
||||
{showDate && featured.publishedAt && (
|
||||
<span>{formatDate(featured.publishedAt)}</span>
|
||||
)}
|
||||
{showCategory && categories[0] && <span className="text-brass font-medium">{categories[0].name}</span>}
|
||||
{showDate && featured.publishedAt && <span>{formatDate(featured.publishedAt)}</span>}
|
||||
</div>
|
||||
<h3 className="text-2xl lg:text-3xl font-semibold mb-3 group-hover:text-brass transition-colors">
|
||||
{featured.title}
|
||||
</h3>
|
||||
{showExcerpt && featured.excerpt && (
|
||||
<p className="text-espresso/80 line-clamp-3">{featured.excerpt}</p>
|
||||
)}
|
||||
<h3 className="text-2xl lg:text-3xl font-semibold mb-3 group-hover:text-brass transition-colors">{featured.title}</h3>
|
||||
{showExcerpt && featured.excerpt && <p className="text-espresso/80 line-clamp-3">{featured.excerpt}</p>}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
|
||||
{/* Secondary Posts */}
|
||||
<div className="space-y-6">
|
||||
{rest.slice(0, 2).map((post) => (
|
||||
<Link key={post.id} href={getPostUrl(post)} className="group block">
|
||||
<article className="flex gap-4 bg-soft-white border border-warm-gray rounded-xl p-4 transition-all duration-300 hover:shadow-lg">
|
||||
{post.featuredImage && (
|
||||
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={post.featuredImage.url}
|
||||
alt={post.featuredImage.alt || post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{showDate && post.publishedAt && (
|
||||
<span className="text-sm text-warm-gray-dark">
|
||||
{formatDate(post.publishedAt)}
|
||||
</span>
|
||||
{rest.slice(0, 2).map((post) => {
|
||||
const img = getMediaUrl(post.featuredImage)
|
||||
return (
|
||||
<Link key={post.id} href={getPostUrl(post)} className="group block">
|
||||
<article className="flex gap-4 bg-soft-white border border-warm-gray rounded-xl p-4 transition-all duration-300 hover:shadow-lg">
|
||||
{img && (
|
||||
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<Image src={img} alt={getMediaAlt(post.featuredImage, post.title)} fill className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<h4 className="font-semibold line-clamp-2 group-hover:text-brass transition-colors">
|
||||
{post.title}
|
||||
</h4>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex-1 min-w-0">
|
||||
{showDate && post.publishedAt && <span className="text-sm text-warm-gray-dark">{formatDate(post.publishedAt)}</span>}
|
||||
<h4 className="font-semibold line-clamp-2 group-hover:text-brass transition-colors">{post.title}</h4>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListLayout({
|
||||
posts,
|
||||
showExcerpt,
|
||||
showDate,
|
||||
showCategory,
|
||||
}: {
|
||||
posts: Post[]
|
||||
showExcerpt?: boolean
|
||||
showDate?: boolean
|
||||
showCategory?: boolean
|
||||
}) {
|
||||
function ListLayout({ posts, showExcerpt, showDate, showCategory }: { posts: Post[]; showExcerpt?: boolean | null; showDate?: boolean | null; showCategory?: boolean | null }) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.id} href={getPostUrl(post)} className="group block">
|
||||
<article className="flex gap-6 bg-soft-white border border-warm-gray rounded-xl p-6 transition-all duration-300 hover:shadow-lg">
|
||||
{post.featuredImage && (
|
||||
<div className="relative w-32 h-32 md:w-48 md:h-32 flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={post.featuredImage.url}
|
||||
alt={post.featuredImage.alt || post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-2">
|
||||
{showCategory && post.categories?.[0] && (
|
||||
<span className="text-brass font-medium">
|
||||
{post.categories[0].title}
|
||||
</span>
|
||||
)}
|
||||
{showDate && post.publishedAt && (
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-brass transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
{showExcerpt && post.excerpt && (
|
||||
<p className="text-espresso/80 line-clamp-2">{post.excerpt}</p>
|
||||
{posts.map((post) => {
|
||||
const img = getMediaUrl(post.featuredImage)
|
||||
const categories = resolveRelationArray<Category>(post.categories)
|
||||
return (
|
||||
<Link key={post.id} href={getPostUrl(post)} className="group block">
|
||||
<article className="flex gap-6 bg-soft-white border border-warm-gray rounded-xl p-6 transition-all duration-300 hover:shadow-lg">
|
||||
{img && (
|
||||
<div className="relative w-32 h-32 md:w-48 md:h-32 flex-shrink-0 rounded-lg overflow-hidden">
|
||||
<Image src={img} alt={getMediaAlt(post.featuredImage, post.title)} fill className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 text-sm text-warm-gray-dark mb-2">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactLayout({
|
||||
posts,
|
||||
showDate,
|
||||
}: {
|
||||
posts: Post[]
|
||||
showDate?: boolean
|
||||
}) {
|
||||
function CompactLayout({ posts, showDate }: { posts: Post[]; showDate?: boolean | null }) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto divide-y divide-warm-gray">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={getPostUrl(post)}
|
||||
className="group block py-4 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Link key={post.id} href={getPostUrl(post)} className="group block py-4 first:pt-0 last:pb-0">
|
||||
<article className="flex items-center justify-between gap-4">
|
||||
<h4 className="font-medium group-hover:text-brass transition-colors">
|
||||
{post.title}
|
||||
</h4>
|
||||
{showDate && post.publishedAt && (
|
||||
<span className="text-sm text-warm-gray-dark whitespace-nowrap">
|
||||
{formatDate(post.publishedAt)}
|
||||
</span>
|
||||
)}
|
||||
<h4 className="font-medium group-hover:text-brass transition-colors">{post.title}</h4>
|
||||
{showDate && post.publishedAt && <span className="text-sm text-warm-gray-dark whitespace-nowrap">{formatDate(post.publishedAt)}</span>}
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -368,6 +246,5 @@ function getPostUrl(post: Post): string {
|
|||
press: '/presse',
|
||||
announcement: '/aktuelles',
|
||||
}
|
||||
const prefix = prefixes[post.type] || '/blog'
|
||||
return `${prefix}/${post.slug}`
|
||||
return `${prefixes[post.type] || '/blog'}/${post.slug}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,35 @@
|
|||
import Image from 'next/image'
|
||||
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 { getSeries } from '@/lib/api'
|
||||
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({
|
||||
title,
|
||||
subtitle,
|
||||
displayMode,
|
||||
selectedSeries,
|
||||
layout = 'grid',
|
||||
showDescription = true,
|
||||
limit,
|
||||
}: SeriesBlockProps) {
|
||||
// Fetch series if not using selected mode
|
||||
let items: Series[] = []
|
||||
|
||||
if (displayMode === 'selected' && selectedSeries) {
|
||||
items = selectedSeries
|
||||
} else {
|
||||
const seriesData = await getSeries()
|
||||
items = seriesData.docs
|
||||
}
|
||||
const seriesData = await getSeries({ limit: limit ?? 20 })
|
||||
const items = seriesData.docs
|
||||
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="container">
|
||||
{/* Section Header */}
|
||||
{(title || subtitle) && (
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
{title && <h2 className="mb-4">{title}</h2>}
|
||||
{subtitle && (
|
||||
<p className="text-lg text-espresso/80">{subtitle}</p>
|
||||
)}
|
||||
{subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Series */}
|
||||
{layout === 'featured' ? (
|
||||
<FeaturedLayout items={items} showDescription={showDescription} />
|
||||
) : layout === 'list' ? (
|
||||
|
|
@ -54,223 +42,89 @@ export async function SeriesBlock({
|
|||
)
|
||||
}
|
||||
|
||||
function GridLayout({
|
||||
items,
|
||||
showDescription,
|
||||
}: {
|
||||
items: Series[]
|
||||
showDescription?: boolean
|
||||
}) {
|
||||
function GridLayout({ items, showDescription }: { items: Series[]; showDescription?: boolean | null }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map((series) => (
|
||||
<SeriesCard
|
||||
key={series.id}
|
||||
series={series}
|
||||
showDescription={showDescription}
|
||||
/>
|
||||
))}
|
||||
{items.map((series) => <SeriesCard key={series.id} series={series} showDescription={showDescription} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListLayout({
|
||||
items,
|
||||
showDescription,
|
||||
}: {
|
||||
items: Series[]
|
||||
showDescription?: boolean
|
||||
}) {
|
||||
function ListLayout({ items, showDescription }: { items: Series[]; showDescription?: boolean | null }) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{items.map((series) => (
|
||||
<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">
|
||||
{/* Logo/Image */}
|
||||
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-ivory flex items-center justify-center">
|
||||
{series.logo ? (
|
||||
<Image
|
||||
src={series.logo.url}
|
||||
alt={series.title}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
) : series.coverImage ? (
|
||||
<Image
|
||||
src={series.coverImage.url}
|
||||
alt={series.title}
|
||||
fill
|
||||
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>
|
||||
))}
|
||||
{items.map((series) => {
|
||||
const logoUrl = getMediaUrl(series.logo)
|
||||
const coverUrl = getMediaUrl(series.coverImage)
|
||||
return (
|
||||
<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">
|
||||
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-ivory flex items-center justify-center">
|
||||
{logoUrl ? (
|
||||
<Image src={logoUrl} alt={series.title} fill className="object-contain p-2" />
|
||||
) : coverUrl ? (
|
||||
<Image src={coverUrl} alt={series.title} fill className="object-cover" />
|
||||
) : (
|
||||
<SeriesPill series={series.slug} size="lg">{series.title.slice(0, 2).toUpperCase()}</SeriesPill>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturedLayout({
|
||||
items,
|
||||
showDescription,
|
||||
}: {
|
||||
items: Series[]
|
||||
showDescription?: boolean
|
||||
}) {
|
||||
function FeaturedLayout({ items, showDescription }: { items: Series[]; showDescription?: boolean | null }) {
|
||||
const [featured, ...rest] = items
|
||||
const coverUrl = getMediaUrl(featured.coverImage)
|
||||
const logoUrl = getMediaUrl(featured.logo)
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Link href={`/serien/${featured.slug}`} className="group block lg:row-span-2">
|
||||
<article className="relative h-full min-h-[400px] rounded-2xl overflow-hidden" style={{ backgroundColor: featured.brandColor || '#C6A47E' }}>
|
||||
{coverUrl && <Image src={coverUrl} alt={featured.title} fill className="object-cover opacity-30" />}
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-8">
|
||||
{featured.logo && (
|
||||
<div className="relative w-32 h-16 mb-4">
|
||||
<Image
|
||||
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>
|
||||
)}
|
||||
{logoUrl && <div className="relative w-32 h-16 mb-4"><Image src={logoUrl} 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>
|
||||
</article>
|
||||
</Link>
|
||||
|
||||
{/* Other Series */}
|
||||
<div className="space-y-6">
|
||||
{rest.slice(0, 3).map((series) => (
|
||||
<SeriesCard
|
||||
key={series.id}
|
||||
series={series}
|
||||
showDescription={false}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
{rest.slice(0, 3).map((series) => <SeriesCard key={series.id} series={series} showDescription={false} compact />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SeriesCardProps {
|
||||
series: Series
|
||||
showDescription?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function SeriesCard({ series, showDescription, compact }: SeriesCardProps) {
|
||||
const imageUrl = getImageUrl(series.coverImage) || getImageUrl(series.logo)
|
||||
function SeriesCard({ series, showDescription, compact }: { series: Series; showDescription?: boolean | null; compact?: boolean }) {
|
||||
const coverUrl = getMediaUrl(series.coverImage)
|
||||
const logoUrl = getMediaUrl(series.logo)
|
||||
const imageUrl = coverUrl || logoUrl
|
||||
|
||||
return (
|
||||
<Link href={`/serien/${series.slug}`} className="group block">
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
{/* Image */}
|
||||
<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')}>
|
||||
{!compact && (
|
||||
<div
|
||||
className="relative aspect-video"
|
||||
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 className="relative aspect-video" style={{ backgroundColor: series.brandColor || '#C6A47E' }}>
|
||||
{imageUrl && <Image src={imageUrl} alt={series.title} fill className={cn(logoUrl && !coverUrl ? 'object-contain p-8' : 'object-cover opacity-50')} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{compact && (
|
||||
<div
|
||||
className="relative w-16 h-16 flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center"
|
||||
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 className="relative w-16 h-16 flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center" style={{ backgroundColor: series.brandColor || '#C6A47E' }}>
|
||||
{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>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn(!compact && 'p-6', compact && 'flex-1 min-w-0')}>
|
||||
<h3
|
||||
className={cn(
|
||||
'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>
|
||||
)}
|
||||
<h3 className={cn('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>
|
||||
</article>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,56 +1,45 @@
|
|||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveRelation, getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
|
||||
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({
|
||||
series,
|
||||
layout = 'hero',
|
||||
showLogo = true,
|
||||
showPlaylistLink = true,
|
||||
useBrandColor = true,
|
||||
series: seriesRef,
|
||||
layout = 'full',
|
||||
showBrandColors = true,
|
||||
showYoutubePlaylist = true,
|
||||
showHero = true,
|
||||
showDescription = true,
|
||||
}: 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
|
||||
? `https://www.youtube.com/playlist?list=${series.youtubePlaylistId}`
|
||||
: null
|
||||
: series.youtubePlaylistUrl || null
|
||||
|
||||
if (layout === 'compact') {
|
||||
return (
|
||||
<section
|
||||
className="py-12"
|
||||
style={{ backgroundColor: useBrandColor ? bgColor : undefined }}
|
||||
>
|
||||
<section className="py-12" style={{ backgroundColor: showBrandColors ? bgColor : undefined }}>
|
||||
<div className="container">
|
||||
<div className="flex items-center gap-6">
|
||||
{showLogo && series.logo && (
|
||||
{logoUrl && (
|
||||
<div className="relative w-24 h-16 flex-shrink-0">
|
||||
<Image
|
||||
src={series.logo.url}
|
||||
alt={series.title}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
<Image src={logoUrl} alt={getMediaAlt(series.logo, series.title)} fill className="object-contain" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className={cn(useBrandColor && 'text-soft-white')}>
|
||||
{series.title}
|
||||
</h1>
|
||||
<h1 className={cn(showBrandColors && 'text-soft-white')}>{series.title}</h1>
|
||||
</div>
|
||||
{showPlaylistLink && playlistUrl && (
|
||||
<Button
|
||||
href={playlistUrl}
|
||||
external
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
useBrandColor &&
|
||||
'border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso'
|
||||
)}
|
||||
>
|
||||
{showYoutubePlaylist && playlistUrl && (
|
||||
<Button href={playlistUrl} external variant="secondary" className={cn(showBrandColors && 'border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso')}>
|
||||
Playlist ansehen
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -60,63 +49,22 @@ export function SeriesDetailBlock({
|
|||
)
|
||||
}
|
||||
|
||||
// Hero layout (default)
|
||||
// Full layout (default)
|
||||
return (
|
||||
<section className="relative min-h-[50vh] md:min-h-[60vh] flex items-center">
|
||||
{/* Background */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{series.coverImage && (
|
||||
<Image
|
||||
src={series.coverImage.url}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover opacity-30"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ backgroundColor: bgColor }}>
|
||||
{showHero && coverUrl && <Image src={coverUrl} alt="" fill className="object-cover opacity-30" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="container relative z-10 py-16">
|
||||
<div className="max-w-3xl">
|
||||
{/* Logo */}
|
||||
{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 */}
|
||||
{logoUrl && <div className="relative w-48 h-20 mb-6"><Image src={logoUrl} alt="" fill className="object-contain object-left" /></div>}
|
||||
<h1 className="text-soft-white mb-6">{series.title}</h1>
|
||||
|
||||
{/* Description */}
|
||||
{series.description && (
|
||||
<div className="text-soft-white/80 text-lg mb-8">
|
||||
<RichTextRenderer content={series.description} />
|
||||
</div>
|
||||
{showDescription && series.description && (
|
||||
<div className="text-soft-white/80 text-lg mb-8"><RichTextRenderer content={series.description} /></div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
{showPlaylistLink && playlistUrl && (
|
||||
<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"
|
||||
>
|
||||
{showYoutubePlaylist && playlistUrl && (
|
||||
<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 fill="white" d="M9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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({
|
||||
title,
|
||||
subtitle,
|
||||
stats,
|
||||
backgroundColor = 'soft-white',
|
||||
style,
|
||||
layout = 'row',
|
||||
}: StatsBlockProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
|
@ -26,117 +26,38 @@ export function StatsBlock({
|
|||
},
|
||||
{ threshold: 0.2 }
|
||||
)
|
||||
|
||||
if (sectionRef.current) {
|
||||
observer.observe(sectionRef.current)
|
||||
}
|
||||
|
||||
if (sectionRef.current) observer.observe(sectionRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const bgClasses = {
|
||||
ivory: 'bg-ivory',
|
||||
'soft-white': 'bg-soft-white',
|
||||
sand: 'bg-sand/20',
|
||||
const bgClasses: Record<string, string> = {
|
||||
none: 'bg-soft-white',
|
||||
light: 'bg-ivory',
|
||||
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 (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={cn('py-16 md:py-24', bgClasses[backgroundColor])}
|
||||
>
|
||||
<section ref={sectionRef} className={cn('py-16 md:py-24', bgClasses[style?.bg || 'none'])}>
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
{(title || subtitle) && (
|
||||
<div className="text-center mb-12 md:mb-16">
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>}
|
||||
{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>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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'
|
||||
)}
|
||||
>
|
||||
<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) => (
|
||||
<div
|
||||
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 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' }}>
|
||||
<div className="relative mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
'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 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')}>
|
||||
{stat.prefix}{stat.value ?? stat.label}{stat.suffix}
|
||||
</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>
|
||||
|
||||
{/* Label */}
|
||||
<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>
|
||||
)}
|
||||
<h3 className="text-lg md:text-xl font-medium mb-2">{stat.label}</h3>
|
||||
{stat.description && <p className="text-sm opacity-60 max-w-xs mx-auto leading-relaxed">{stat.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
import { useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
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[]
|
||||
}
|
||||
|
||||
|
|
@ -14,28 +15,25 @@ export function TestimonialsBlock({
|
|||
subtitle,
|
||||
displayMode,
|
||||
selectedTestimonials,
|
||||
layout = 'carousel',
|
||||
layout = 'slider',
|
||||
testimonials: externalTestimonials,
|
||||
}: TestimonialsBlockProps) {
|
||||
const items = displayMode === 'selected' ? selectedTestimonials : externalTestimonials
|
||||
const items = displayMode === 'selected'
|
||||
? resolveRelationArray<Testimonial>(selectedTestimonials)
|
||||
: externalTestimonials || []
|
||||
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-20 bg-soft-white">
|
||||
<div className="container">
|
||||
{/* Section Header */}
|
||||
{(title || subtitle) && (
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
{title && <h2 className="mb-4">{title}</h2>}
|
||||
{subtitle && (
|
||||
<p className="text-lg text-espresso/80">{subtitle}</p>
|
||||
)}
|
||||
{subtitle && <p className="text-lg text-espresso/80">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Testimonials */}
|
||||
{layout === 'carousel' ? (
|
||||
{layout === 'slider' ? (
|
||||
<CarouselLayout items={items} />
|
||||
) : layout === 'grid' ? (
|
||||
<GridLayout items={items} />
|
||||
|
|
@ -49,7 +47,6 @@ export function TestimonialsBlock({
|
|||
|
||||
function CarouselLayout({ items }: { items: Testimonial[] }) {
|
||||
const [current, setCurrent] = useState(0)
|
||||
|
||||
const prev = () => setCurrent((i) => (i === 0 ? items.length - 1 : i - 1))
|
||||
const next = () => setCurrent((i) => (i === items.length - 1 ? 0 : i + 1))
|
||||
|
||||
|
|
@ -57,70 +54,23 @@ function CarouselLayout({ items }: { items: Testimonial[] }) {
|
|||
<div className="max-w-3xl mx-auto">
|
||||
<div className="relative">
|
||||
{items.map((testimonial, index) => (
|
||||
<div
|
||||
key={testimonial.id}
|
||||
className={cn(
|
||||
'transition-opacity duration-500',
|
||||
index === current ? 'opacity-100' : 'opacity-0 absolute inset-0'
|
||||
)}
|
||||
>
|
||||
<div key={testimonial.id} className={cn('transition-opacity duration-500', index === current ? 'opacity-100' : 'opacity-0 absolute inset-0')}>
|
||||
<TestimonialCard testimonial={testimonial} variant="featured" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{items.length > 1 && (
|
||||
<div className="flex justify-center items-center gap-4 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
className="p-2 rounded-full border border-warm-gray hover:border-brass hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2"
|
||||
aria-label="Vorheriges Testimonial"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
<button 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /></svg>
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{items.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => setCurrent(index)}
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2',
|
||||
index === current ? 'bg-brass' : 'bg-warm-gray'
|
||||
)}
|
||||
aria-label={`Testimonial ${index + 1}`}
|
||||
/>
|
||||
<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}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="p-2 rounded-full border border-warm-gray hover:border-brass hover:text-brass transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brass focus-visible:ring-offset-2"
|
||||
aria-label="Nächstes Testimonial"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
<button 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -131,9 +81,7 @@ function CarouselLayout({ items }: { items: Testimonial[] }) {
|
|||
function GridLayout({ items }: { items: Testimonial[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map((testimonial) => (
|
||||
<TestimonialCard key={testimonial.id} testimonial={testimonial} />
|
||||
))}
|
||||
{items.map((t) => <TestimonialCard key={t.id} testimonial={t} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -141,85 +89,37 @@ function GridLayout({ items }: { items: Testimonial[] }) {
|
|||
function ListLayout({ items }: { items: Testimonial[] }) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
{items.map((testimonial) => (
|
||||
<TestimonialCard key={testimonial.id} testimonial={testimonial} variant="wide" />
|
||||
))}
|
||||
{items.map((t) => <TestimonialCard key={t.id} testimonial={t} variant="wide" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TestimonialCardProps {
|
||||
testimonial: Testimonial
|
||||
variant?: 'default' | 'featured' | 'wide'
|
||||
}
|
||||
function TestimonialCard({ testimonial, variant = 'default' }: { testimonial: Testimonial; variant?: 'default' | 'featured' | 'wide' }) {
|
||||
const avatarUrl = getMediaUrl(testimonial.image)
|
||||
|
||||
function TestimonialCard({ testimonial, variant = 'default' }: TestimonialCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-ivory border-l-4 border-brass rounded-lg p-8',
|
||||
variant === 'featured' && 'text-center border-l-0 border-t-4'
|
||||
)}
|
||||
>
|
||||
{/* Quote */}
|
||||
<blockquote
|
||||
className={cn(
|
||||
'font-headline text-xl font-medium italic text-espresso leading-relaxed mb-6',
|
||||
variant === 'featured' && 'text-2xl'
|
||||
)}
|
||||
>
|
||||
<div className={cn('bg-ivory border-l-4 border-brass rounded-lg p-8', variant === 'featured' && 'text-center border-l-0 border-t-4')}>
|
||||
<blockquote className={cn('font-headline text-xl font-medium italic text-espresso leading-relaxed mb-6', variant === 'featured' && 'text-2xl')}>
|
||||
“{testimonial.quote}”
|
||||
</blockquote>
|
||||
|
||||
{/* Author */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-4',
|
||||
variant === 'featured' && 'justify-center'
|
||||
)}
|
||||
>
|
||||
{testimonial.authorImage?.url && (
|
||||
<Image
|
||||
src={testimonial.authorImage.url}
|
||||
alt={testimonial.authorName}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className={cn('flex items-center gap-4', variant === 'featured' && 'justify-center')}>
|
||||
{avatarUrl && (
|
||||
<Image src={avatarUrl} alt={getMediaAlt(testimonial.image, testimonial.author)} width={48} height={48} className="w-12 h-12 rounded-full object-cover" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold text-espresso">{testimonial.authorName}</p>
|
||||
{(testimonial.authorTitle || testimonial.authorCompany) && (
|
||||
<p className="font-semibold text-espresso">{testimonial.author}</p>
|
||||
{(testimonial.role || testimonial.company) && (
|
||||
<p className="text-sm text-warm-gray-dark">
|
||||
{[testimonial.authorTitle, testimonial.authorCompany]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
{[testimonial.role, testimonial.company].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
{testimonial.rating && (
|
||||
<div className={cn('flex gap-1 mt-4', variant === 'featured' && 'justify-center')}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<svg
|
||||
key={i}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill={i < testimonial.rating! ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
className={cn(
|
||||
'w-5 h-5',
|
||||
i < testimonial.rating! ? 'text-gold' : 'text-warm-gray'
|
||||
)}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
||||
/>
|
||||
<svg key={i} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={i < testimonial.rating! ? 'currentColor' : 'none'} stroke="currentColor" className={cn('w-5 h-5', i < testimonial.rating! ? 'text-gold' : 'text-warm-gray')}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,23 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { TextBlock as TextBlockType } from '@/lib/types'
|
||||
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({
|
||||
content,
|
||||
alignment = 'left',
|
||||
maxWidth = 'lg',
|
||||
width = 'medium',
|
||||
}: TextBlockProps) {
|
||||
const alignmentClasses = {
|
||||
left: 'text-left',
|
||||
center: 'text-center mx-auto',
|
||||
right: 'text-right ml-auto',
|
||||
}
|
||||
|
||||
const maxWidthClasses = {
|
||||
sm: 'max-w-xl',
|
||||
md: 'max-w-2xl',
|
||||
lg: 'max-w-4xl',
|
||||
const widthClasses: Record<string, string> = {
|
||||
narrow: 'max-w-xl',
|
||||
medium: 'max-w-2xl',
|
||||
full: 'max-w-none',
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="container">
|
||||
<div className={cn(alignmentClasses[alignment], maxWidthClasses[maxWidth])}>
|
||||
<div className={cn('mx-auto', widthClasses[width || 'medium'])}>
|
||||
<RichTextRenderer content={content} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,87 +3,57 @@
|
|||
import { useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { cn, extractYouTubeId, getPrivacyYouTubeUrl, getYouTubeThumbnail } from '@/lib/utils'
|
||||
import type { VideoBlock as VideoBlockType } from '@/lib/types'
|
||||
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({
|
||||
title,
|
||||
caption,
|
||||
videoUrl,
|
||||
thumbnailImage,
|
||||
thumbnail,
|
||||
aspectRatio = '16:9',
|
||||
sourceType,
|
||||
}: VideoBlockProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const videoId = extractYouTubeId(videoUrl)
|
||||
const thumbnailUrl = thumbnailImage?.url || (videoId ? getYouTubeThumbnail(videoId) : null)
|
||||
const videoId = videoUrl ? extractYouTubeId(videoUrl) : null
|
||||
const thumbUrl = getMediaUrl(thumbnail) || (videoId ? getYouTubeThumbnail(videoId) : null)
|
||||
const embedUrl = videoId ? getPrivacyYouTubeUrl(videoId) : null
|
||||
|
||||
const aspectClasses = {
|
||||
const aspectClasses: Record<string, string> = {
|
||||
'16:9': 'aspect-video',
|
||||
'4:3': 'aspect-[4/3]',
|
||||
'1:1': 'aspect-square',
|
||||
'9:16': 'aspect-[9/16]',
|
||||
'21:9': 'aspect-[21/9]',
|
||||
}
|
||||
|
||||
if (!embedUrl) {
|
||||
return null
|
||||
}
|
||||
// Only handle embed/external sources with a URL for now
|
||||
if (!embedUrl && sourceType !== 'upload') return null
|
||||
|
||||
return (
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="container">
|
||||
{title && (
|
||||
<h2 className="text-center mb-8">{title}</h2>
|
||||
)}
|
||||
|
||||
{caption && <h2 className="text-center mb-8">{caption}</h2>}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-2xl overflow-hidden bg-espresso',
|
||||
aspectClasses[aspectRatio]
|
||||
)}
|
||||
>
|
||||
<div className={cn('relative rounded-2xl overflow-hidden bg-espresso', aspectClasses[aspectRatio || '16:9'])}>
|
||||
{isPlaying ? (
|
||||
<iframe
|
||||
src={`${embedUrl}?autoplay=1&rel=0`}
|
||||
title={title || 'Video'}
|
||||
title={caption || 'Video'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPlaying(true)}
|
||||
className="absolute inset-0 w-full h-full group"
|
||||
aria-label="Video abspielen"
|
||||
>
|
||||
{thumbnailUrl && (
|
||||
<Image
|
||||
src={thumbnailUrl}
|
||||
alt={title || 'Video Thumbnail'}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<button type="button" onClick={() => setIsPlaying(true)} className="absolute inset-0 w-full h-full group" aria-label="Video abspielen">
|
||||
{thumbUrl && <Image src={thumbUrl} alt={caption || 'Video Thumbnail'} fill className="object-cover" />}
|
||||
<div className="absolute inset-0 bg-espresso/50 group-hover:bg-espresso/40 transition-colors" />
|
||||
|
||||
{/* Play Button */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-soft-white/90 flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-8 h-8 md:w-10 md:h-10 text-brass ml-1"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-8 h-8 md:w-10 md:h-10 text-brass ml-1">
|
||||
<path fillRule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,43 +3,45 @@
|
|||
import { useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
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({
|
||||
videoUrl,
|
||||
title,
|
||||
aspectRatio = '16:9',
|
||||
videoSource,
|
||||
youtubeUrl,
|
||||
vimeoUrl,
|
||||
customUrl,
|
||||
thumbnail,
|
||||
caption,
|
||||
privacyMode = true,
|
||||
autoplay = false,
|
||||
showControls = true,
|
||||
thumbnailImage,
|
||||
aspectRatio = '16:9',
|
||||
playbackOptions,
|
||||
}: VideoEmbedBlockProps) {
|
||||
const autoplay = playbackOptions?.autoplay ?? false
|
||||
const showControls = playbackOptions?.showControls ?? true
|
||||
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
|
||||
? privacyMode
|
||||
? getPrivacyYouTubeUrl(videoId)
|
||||
: `https://www.youtube.com/embed/${videoId}`
|
||||
? privacyMode ? getPrivacyYouTubeUrl(videoId) : `https://www.youtube.com/embed/${videoId}`
|
||||
: null
|
||||
|
||||
const thumbnailUrl =
|
||||
thumbnailImage?.url || (videoId ? getYouTubeThumbnail(videoId) : null)
|
||||
const thumbUrl = getMediaUrl(thumbnail) || (videoId ? getYouTubeThumbnail(videoId) : null)
|
||||
|
||||
const aspectClasses = {
|
||||
const aspectClasses: Record<string, string> = {
|
||||
'16:9': 'aspect-video',
|
||||
'4:3': 'aspect-[4/3]',
|
||||
'1:1': 'aspect-square',
|
||||
'9:16': 'aspect-[9/16]',
|
||||
}
|
||||
|
||||
if (!embedUrl) {
|
||||
return null
|
||||
}
|
||||
if (!embedUrl) return null
|
||||
|
||||
// Build embed params
|
||||
const params = new URLSearchParams()
|
||||
if (isPlaying || autoplay) params.append('autoplay', '1')
|
||||
params.append('rel', '0')
|
||||
|
|
@ -47,65 +49,30 @@ export function VideoEmbedBlock({
|
|||
const embedSrc = `${embedUrl}?${params.toString()}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-xl overflow-hidden bg-espresso',
|
||||
aspectClasses[aspectRatio]
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<iframe
|
||||
src={embedSrc}
|
||||
title={title || 'Video'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPlaying(true)}
|
||||
className="absolute inset-0 w-full h-full group"
|
||||
aria-label={`Video abspielen${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 className="py-8">
|
||||
{title && <h3 className="text-lg font-semibold mb-4 text-center">{title}</h3>}
|
||||
<div className={cn('relative rounded-xl overflow-hidden bg-espresso', aspectClasses[aspectRatio || '16:9'])}>
|
||||
{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" />
|
||||
) : (
|
||||
<button type="button" onClick={() => setIsPlaying(true)} className="absolute inset-0 w-full h-full group" aria-label={`Video abspielen${title ? `: ${title}` : ''}`}>
|
||||
{thumbUrl && <Image src={thumbUrl} alt={title || caption || 'Video Thumbnail'} fill className="object-cover" />}
|
||||
<div className="absolute inset-0 bg-espresso/30 group-hover:bg-espresso/40 transition-colors" />
|
||||
<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>
|
||||
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{caption && (
|
||||
<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">{caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { SeriesBlock } from './SeriesBlock'
|
|||
import { SeriesDetailBlock } from './SeriesDetailBlock'
|
||||
import { VideoEmbedBlock } from './VideoEmbedBlock'
|
||||
import { StatsBlock } from './StatsBlock'
|
||||
import type { Block } from '@/lib/types'
|
||||
import type { Block } from '@c2s/payload-contracts/types'
|
||||
|
||||
// Map block types to components
|
||||
const blockComponents: Record<string, React.ComponentType<Record<string, unknown>>> = {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import type { Navigation as NavigationType } from '@c2s/payload-contracts/types'
|
||||
import type { SiteSettings } from '@/lib/types'
|
||||
import type { Navigation as NavigationType, SiteSetting, SocialLink } from '@c2s/payload-contracts/types'
|
||||
import { getMediaUrl, getMediaAlt, socialLinksToMap } from '@/lib/payload-helpers'
|
||||
|
||||
type FooterMenuItem = NonNullable<NavigationType['footerMenu']>[number]
|
||||
|
||||
interface FooterProps {
|
||||
footerMenu: FooterMenuItem[] | null
|
||||
settings: SiteSettings | null
|
||||
settings: SiteSetting | null
|
||||
socialLinks?: SocialLink[]
|
||||
}
|
||||
|
||||
function getPageSlug(page: FooterMenuItem['page']): string | undefined {
|
||||
|
|
@ -15,8 +16,10 @@ function getPageSlug(page: FooterMenuItem['page']): string | undefined {
|
|||
return undefined
|
||||
}
|
||||
|
||||
export function Footer({ footerMenu, settings }: FooterProps) {
|
||||
export function Footer({ footerMenu, settings, socialLinks }: FooterProps) {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const logoUrl = getMediaUrl(settings?.logo)
|
||||
const socials = socialLinksToMap(socialLinks)
|
||||
|
||||
return (
|
||||
<footer className="bg-espresso text-soft-white">
|
||||
|
|
@ -26,10 +29,10 @@ export function Footer({ footerMenu, settings }: FooterProps) {
|
|||
{/* Brand Column */}
|
||||
<div className="lg:col-span-1">
|
||||
<Link href="/" className="inline-block mb-4">
|
||||
{settings?.logo ? (
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={settings.logo.url}
|
||||
alt={settings.siteName || 'BlogWoman'}
|
||||
src={logoUrl}
|
||||
alt={getMediaAlt(settings?.logo, settings?.siteName || 'BlogWoman')}
|
||||
width={140}
|
||||
height={35}
|
||||
className="h-9 w-auto brightness-0 invert"
|
||||
|
|
@ -45,22 +48,22 @@ export function Footer({ footerMenu, settings }: FooterProps) {
|
|||
</p>
|
||||
|
||||
{/* Social Links */}
|
||||
{settings?.socialLinks && (
|
||||
{Object.keys(socials).length > 0 && (
|
||||
<div className="flex gap-4 mt-6">
|
||||
{settings.socialLinks.instagram && (
|
||||
<SocialAnchor href={settings.socialLinks.instagram} label="Instagram" platform="instagram" />
|
||||
{socials.instagram && (
|
||||
<SocialAnchor href={socials.instagram} label="Instagram" platform="instagram" />
|
||||
)}
|
||||
{settings.socialLinks.youtube && (
|
||||
<SocialAnchor href={settings.socialLinks.youtube} label="YouTube" platform="youtube" />
|
||||
{socials.youtube && (
|
||||
<SocialAnchor href={socials.youtube} label="YouTube" platform="youtube" />
|
||||
)}
|
||||
{settings.socialLinks.pinterest && (
|
||||
<SocialAnchor href={settings.socialLinks.pinterest} label="Pinterest" platform="pinterest" />
|
||||
{socials.facebook && (
|
||||
<SocialAnchor href={socials.facebook} label="Facebook" platform="facebook" />
|
||||
)}
|
||||
{settings.socialLinks.tiktok && (
|
||||
<SocialAnchor href={settings.socialLinks.tiktok} label="TikTok" platform="tiktok" />
|
||||
{socials.x && (
|
||||
<SocialAnchor href={socials.x} label="X" platform="x" />
|
||||
)}
|
||||
{settings.socialLinks.facebook && (
|
||||
<SocialAnchor href={settings.socialLinks.facebook} label="Facebook" platform="facebook" />
|
||||
{socials.linkedin && (
|
||||
<SocialAnchor href={socials.linkedin} label="LinkedIn" platform="linkedin" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -83,29 +86,29 @@ export function Footer({ footerMenu, settings }: FooterProps) {
|
|||
)}
|
||||
|
||||
{/* Contact Column */}
|
||||
{(settings?.email || settings?.phone || settings?.address) && (
|
||||
{(settings?.contact?.email || settings?.contact?.phone || settings?.address) && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold tracking-[0.1em] uppercase text-brass mb-4">
|
||||
Kontakt
|
||||
</h3>
|
||||
<address className="not-italic space-y-2 text-sm">
|
||||
{settings.email && (
|
||||
{settings.contact?.email && (
|
||||
<p>
|
||||
<a
|
||||
href={`mailto:${settings.email}`}
|
||||
href={`mailto:${settings.contact.email}`}
|
||||
className="text-soft-white hover:text-sand transition-colors"
|
||||
>
|
||||
{settings.email}
|
||||
{settings.contact.email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{settings.phone && (
|
||||
{settings.contact?.phone && (
|
||||
<p>
|
||||
<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"
|
||||
>
|
||||
{settings.phone}
|
||||
{settings.contact.phone}
|
||||
</a>
|
||||
</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" />
|
||||
</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:
|
||||
return (
|
||||
<svg className={iconClass} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
|
|||
|
|
@ -5,18 +5,19 @@ import Link from 'next/link'
|
|||
import Image from 'next/image'
|
||||
import { Navigation } from './Navigation'
|
||||
import { MobileMenu } from './MobileMenu'
|
||||
import type { Navigation as NavigationType } from '@c2s/payload-contracts/types'
|
||||
import type { SiteSettings } from '@/lib/types'
|
||||
import type { Navigation as NavigationType, SiteSetting } from '@c2s/payload-contracts/types'
|
||||
import { getMediaUrl, getMediaAlt } from '@/lib/payload-helpers'
|
||||
|
||||
type MainMenuItem = NonNullable<NavigationType['mainMenu']>[number]
|
||||
|
||||
interface HeaderProps {
|
||||
mainMenu: MainMenuItem[] | null
|
||||
settings: SiteSettings | null
|
||||
settings: SiteSetting | null
|
||||
}
|
||||
|
||||
export function Header({ mainMenu, settings }: HeaderProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const logoUrl = getMediaUrl(settings?.logo)
|
||||
|
||||
return (
|
||||
<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="/"
|
||||
className="font-headline text-2xl font-semibold text-espresso tracking-tight hover:text-brass transition-colors"
|
||||
>
|
||||
{settings?.logo ? (
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={settings.logo.url}
|
||||
alt={settings.siteName || 'BlogWoman'}
|
||||
src={logoUrl}
|
||||
alt={getMediaAlt(settings?.logo, settings?.siteName || 'BlogWoman')}
|
||||
width={160}
|
||||
height={40}
|
||||
className="h-10 w-auto"
|
||||
|
|
|
|||
|
|
@ -2,25 +2,22 @@
|
|||
* Payload CMS API functions — powered by @c2s/payload-contracts
|
||||
*
|
||||
* Uses the shared API client for transport (tenant isolation, fetch caching).
|
||||
* Returns data typed with local interfaces for component compatibility.
|
||||
*
|
||||
* 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.).
|
||||
* All types come directly from contracts — no bridge casts needed.
|
||||
*/
|
||||
import { cms } from "./cms"
|
||||
import type { Navigation } from "@c2s/payload-contracts/types"
|
||||
import type {
|
||||
Navigation,
|
||||
Page,
|
||||
Post,
|
||||
Favorite,
|
||||
Series,
|
||||
Testimonial,
|
||||
FAQ,
|
||||
SeoSettings,
|
||||
Faq,
|
||||
SiteSetting,
|
||||
SeoSetting,
|
||||
SocialLink,
|
||||
PaginatedResponse,
|
||||
SiteSettings,
|
||||
} from "./types"
|
||||
} from "@c2s/payload-contracts/types"
|
||||
|
||||
export type { Navigation }
|
||||
|
||||
|
|
@ -28,11 +25,10 @@ const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID || "9"
|
|||
|
||||
// Pages
|
||||
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",
|
||||
depth: 2,
|
||||
})
|
||||
return result as unknown as Page | null
|
||||
}
|
||||
|
||||
export async function getPages(options: {
|
||||
|
|
@ -40,22 +36,20 @@ export async function getPages(options: {
|
|||
page?: number
|
||||
locale?: string
|
||||
} = {}): Promise<PaginatedResponse<Page>> {
|
||||
const result = await cms.pages.getPages({
|
||||
return cms.pages.getPages({
|
||||
limit: options.limit || 100,
|
||||
page: options.page || 1,
|
||||
locale: (options.locale || "de") as "de" | "en",
|
||||
depth: 1,
|
||||
})
|
||||
return result as unknown as PaginatedResponse<Page>
|
||||
}
|
||||
|
||||
// Posts
|
||||
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",
|
||||
depth: 2,
|
||||
})
|
||||
return result as unknown as Post | null
|
||||
}
|
||||
|
||||
export async function getPosts(options: {
|
||||
|
|
@ -71,7 +65,7 @@ export async function getPosts(options: {
|
|||
const where: Record<string, unknown> = {}
|
||||
if (options.featured) where["isFeatured][equals"] = "true"
|
||||
|
||||
const result = await cms.posts.getPosts({
|
||||
return await cms.posts.getPosts({
|
||||
type: options.type,
|
||||
category: options.category,
|
||||
series: options.series,
|
||||
|
|
@ -80,14 +74,12 @@ export async function getPosts(options: {
|
|||
locale: (options.locale || "de") as "de" | "en",
|
||||
where,
|
||||
})
|
||||
return result as unknown as PaginatedResponse<Post>
|
||||
} catch {
|
||||
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
|
||||
// Uses contracts Navigation type directly (no bridge needed)
|
||||
export async function getNavigation(): Promise<Navigation | null> {
|
||||
try {
|
||||
return await cms.navigation.getNavigation({ depth: 2 })
|
||||
|
|
@ -97,36 +89,47 @@ export async function getNavigation(): Promise<Navigation | null> {
|
|||
}
|
||||
|
||||
// Site Settings
|
||||
export async function getSiteSettings(): Promise<SiteSettings | null> {
|
||||
export async function getSiteSettings(): Promise<SiteSetting | null> {
|
||||
try {
|
||||
const result = await cms.settings.getSiteSettings({ depth: 2 })
|
||||
return result as unknown as SiteSettings | null
|
||||
return await cms.settings.getSiteSettings({ depth: 2 })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// SEO Settings (Global)
|
||||
export async function getSeoSettings(): Promise<SeoSettings | null> {
|
||||
export async function getSeoSettings(): Promise<SeoSetting | null> {
|
||||
try {
|
||||
const result = await cms.settings.getSeoSettings()
|
||||
return result as unknown as SeoSettings | null
|
||||
return await cms.settings.getSeoSettings()
|
||||
} catch {
|
||||
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
|
||||
export async function getTestimonials(options: {
|
||||
limit?: number
|
||||
locale?: string
|
||||
} = {}): Promise<PaginatedResponse<Testimonial>> {
|
||||
const result = await cms.client.getCollection("testimonials", {
|
||||
return await cms.client.getCollection("testimonials", {
|
||||
limit: options.limit || 10,
|
||||
locale: (options.locale || "de") as "de" | "en",
|
||||
depth: 1,
|
||||
})
|
||||
return result as unknown as PaginatedResponse<Testimonial>
|
||||
}) as PaginatedResponse<Testimonial>
|
||||
}
|
||||
|
||||
// FAQs
|
||||
|
|
@ -134,15 +137,14 @@ export async function getFAQs(options: {
|
|||
category?: string
|
||||
limit?: number
|
||||
locale?: string
|
||||
} = {}): Promise<PaginatedResponse<FAQ>> {
|
||||
const result = await cms.client.getCollection("faqs", {
|
||||
} = {}): Promise<PaginatedResponse<Faq>> {
|
||||
return await cms.client.getCollection("faqs", {
|
||||
limit: options.limit || 50,
|
||||
locale: (options.locale || "de") as "de" | "en",
|
||||
sort: "order",
|
||||
depth: 1,
|
||||
where: options.category ? { "category][equals": options.category } : undefined,
|
||||
})
|
||||
return result as unknown as PaginatedResponse<FAQ>
|
||||
}) as PaginatedResponse<Faq>
|
||||
}
|
||||
|
||||
// BlogWoman: Favorites
|
||||
|
|
@ -158,14 +160,13 @@ export async function getFavorites(options: {
|
|||
if (options.category) where["category][equals"] = options.category
|
||||
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,
|
||||
page: options.page || 1,
|
||||
locale: (options.locale || "de") as "de" | "en",
|
||||
depth: 1,
|
||||
where,
|
||||
})
|
||||
return result as unknown as PaginatedResponse<Favorite>
|
||||
}) as PaginatedResponse<Favorite>
|
||||
} catch {
|
||||
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,
|
||||
where: { "slug][equals": slug, "isActive][equals": "true" },
|
||||
limit: 1,
|
||||
})
|
||||
return (data.docs[0] as unknown as Favorite) ?? null
|
||||
}) as PaginatedResponse<Favorite>
|
||||
return data.docs[0] ?? null
|
||||
}
|
||||
|
||||
// BlogWoman: Series
|
||||
|
|
@ -187,13 +188,12 @@ export async function getSeries(options: {
|
|||
locale?: string
|
||||
} = {}): Promise<PaginatedResponse<Series>> {
|
||||
try {
|
||||
const result = await cms.client.getCollection("series", {
|
||||
return await cms.client.getCollection("series", {
|
||||
limit: options.limit || 20,
|
||||
locale: (options.locale || "de") as "de" | "en",
|
||||
depth: 2,
|
||||
where: { "isActive][equals": "true" },
|
||||
})
|
||||
return result as unknown as PaginatedResponse<Series>
|
||||
}) as PaginatedResponse<Series>
|
||||
} catch {
|
||||
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,
|
||||
where: { "slug][equals": slug, "isActive][equals": "true" },
|
||||
limit: 1,
|
||||
})
|
||||
return (data.docs[0] as unknown as Series) ?? null
|
||||
}) as PaginatedResponse<Series>
|
||||
return data.docs[0] ?? null
|
||||
}
|
||||
|
||||
// Newsletter Subscription
|
||||
|
|
|
|||
74
src/lib/payload-helpers.ts
Normal file
74
src/lib/payload-helpers.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
import { absoluteUrl, getImageUrl } from './utils'
|
||||
import type { Post, Series, Favorite, SiteSettings, Author } from './types'
|
||||
import { absoluteUrl } from './utils'
|
||||
import { getMediaUrl, resolveRelation } from './payload-helpers'
|
||||
import type { Post, Series, Favorite, SiteSetting, Author } from '@c2s/payload-contracts/types'
|
||||
|
||||
/**
|
||||
* Generate WebSite schema for the homepage
|
||||
*/
|
||||
export function generateWebSiteSchema(settings?: SiteSettings | null) {
|
||||
export function generateWebSiteSchema(settings?: SiteSetting | null) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: settings?.siteName || 'BlogWoman',
|
||||
description: settings?.siteDescription || 'Lifestyle-Blog für moderne Frauen',
|
||||
description: settings?.seo?.defaultMetaDescription || 'Lifestyle-Blog fuer moderne Frauen',
|
||||
url: absoluteUrl('/'),
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
|
|
@ -25,20 +26,14 @@ export function generateWebSiteSchema(settings?: SiteSettings | null) {
|
|||
/**
|
||||
* Generate Organization schema
|
||||
*/
|
||||
export function generateOrganizationSchema(settings?: SiteSettings | null) {
|
||||
const logoUrl = settings?.logo?.url || '/logo.png'
|
||||
export function generateOrganizationSchema(settings?: SiteSetting | null) {
|
||||
const logoUrl = getMediaUrl(settings?.logo) || '/logo.png'
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: settings?.siteName || 'BlogWoman',
|
||||
url: absoluteUrl('/'),
|
||||
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(
|
||||
post: Post,
|
||||
settings?: SiteSettings | null
|
||||
settings?: SiteSetting | null
|
||||
) {
|
||||
const author = post.author as Author | undefined
|
||||
const imageUrl = getImageUrl(post.featuredImage)
|
||||
const logoUrl = settings?.logo?.url || '/logo.png'
|
||||
const author = resolveRelation<Author>(post.author)
|
||||
const imageUrl = getMediaUrl(post.featuredImage)
|
||||
const logoUrl = getMediaUrl(settings?.logo) || '/logo.png'
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
|
|
@ -131,7 +126,6 @@ export function generateVideoSchema(
|
|||
thumbnailUrl?: string,
|
||||
uploadDate?: string
|
||||
) {
|
||||
// Extract YouTube video ID
|
||||
const videoIdMatch = videoUrl.match(
|
||||
/(?: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
|
||||
*/
|
||||
export function generateProductSchema(favorite: Favorite) {
|
||||
const imageUrl = getImageUrl(favorite.image)
|
||||
const imageUrl = getMediaUrl(favorite.image)
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
|
|
@ -164,10 +158,10 @@ export function generateProductSchema(favorite: Favorite) {
|
|||
name: favorite.title,
|
||||
description: favorite.description,
|
||||
image: imageUrl || undefined,
|
||||
offers: favorite.price
|
||||
offers: favorite.price != null
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
price: favorite.price.replace(/[^0-9.,]/g, ''),
|
||||
price: String(favorite.price),
|
||||
priceCurrency: 'EUR',
|
||||
availability: 'https://schema.org/InStock',
|
||||
url: favorite.affiliateUrl,
|
||||
|
|
@ -199,8 +193,8 @@ export function generatePersonSchema(author: Author) {
|
|||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
jobTitle: author.role,
|
||||
image: author.avatar?.url,
|
||||
|
||||
image: getMediaUrl(author.avatar),
|
||||
url: absoluteUrl('/about'),
|
||||
}
|
||||
}
|
||||
|
|
@ -209,13 +203,13 @@ export function generatePersonSchema(author: Author) {
|
|||
* Generate CollectionPage schema for series
|
||||
*/
|
||||
export function generateSeriesSchema(series: Series) {
|
||||
const imageUrl = getImageUrl(series.coverImage)
|
||||
const imageUrl = getMediaUrl(series.coverImage)
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: series.title,
|
||||
description: series.meta?.description,
|
||||
description: series.tagline || undefined,
|
||||
image: imageUrl || undefined,
|
||||
url: absoluteUrl(`/serien/${series.slug}`),
|
||||
...(series.youtubePlaylistId && {
|
||||
|
|
|
|||
523
src/lib/types.ts
523
src/lib/types.ts
|
|
@ -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 {
|
||||
id: string
|
||||
url: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
mimeType?: string
|
||||
filename?: string
|
||||
}
|
||||
// Collection types
|
||||
export type {
|
||||
Page,
|
||||
Post,
|
||||
Media,
|
||||
Category,
|
||||
Tag,
|
||||
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 {
|
||||
root: {
|
||||
children: unknown[]
|
||||
direction: string | null
|
||||
format: string
|
||||
indent: number
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue