Aktualisierung der Codebase

This commit is contained in:
CCS Admin 2026-02-07 21:34:14 +00:00
parent 75f31b1cb8
commit ba54d7a85d
42 changed files with 5304 additions and 87 deletions

199
CLAUDE.md Normal file
View file

@ -0,0 +1,199 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
BlogWoman.de frontend - a Next.js 16 application consuming Payload CMS as a headless backend. This is a multi-tenant system where **Tenant ID 9 (slug: blogwoman)** is the target tenant.
**Tech Stack:** Next.js 16, React 19, TypeScript, Tailwind CSS 4
**Backend API:** https://cms.c2sgmbh.de/api (Production)
**Package Manager:** pnpm
## Commands
```bash
pnpm dev # Development server (localhost:3000)
pnpm build # Production build
pnpm start # Start production server
pnpm lint # ESLint
```
## Environment Variables
Create `.env.local`:
```env
NEXT_PUBLIC_PAYLOAD_URL=https://cms.c2sgmbh.de
NEXT_PUBLIC_API_URL=https://cms.c2sgmbh.de/api
NEXT_PUBLIC_TENANT_ID=9
NEXT_PUBLIC_TENANT_SLUG=blogwoman
NEXT_PUBLIC_SITE_URL=https://blogwoman.de
NEXT_PUBLIC_UMAMI_HOST=https://analytics.c2sgmbh.de
NEXT_PUBLIC_UMAMI_WEBSITE_ID=<website-id>
```
## Architecture
### Directory Structure
```
src/
├── app/ # Next.js App Router
│ ├── layout.tsx # Root layout (Header/Footer/Analytics)
│ ├── page.tsx # Home page
│ ├── [slug]/page.tsx # Dynamic pages from Payload
│ ├── blog/ # Blog routes
│ ├── serien/ # BlogWoman YouTube series pages
│ └── favoriten/ # BlogWoman affiliate products
├── components/
│ ├── layout/ # Header, Footer, Navigation, MobileMenu
│ ├── blocks/ # CMS block components (see Block System below)
│ ├── ui/ # Reusable UI components
│ └── analytics/ # UmamiScript
├── lib/
│ ├── api.ts # Payload API client functions
│ ├── types.ts # TypeScript types
│ └── structuredData.ts # JSON-LD helpers for SEO
└── hooks/
└── useAnalytics.ts # Umami event tracking
```
### API Pattern - CRITICAL
**Every API call MUST include tenant filtering:**
```typescript
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL
const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID
// CORRECT - always filter by tenant
fetch(`${PAYLOAD_URL}/api/pages?where[tenant][equals]=${TENANT_ID}&where[slug][equals]=about`)
// WRONG - will return 403 or empty results
fetch(`${PAYLOAD_URL}/api/pages?where[slug][equals]=about`)
```
### Block System
Pages from Payload contain a `layout` array of blocks. Each block has a `blockType` field:
| Block Type | Purpose |
|------------|---------|
| `hero-block` | Hero banner with image |
| `hero-slider-block` | Multi-slide hero carousel |
| `text-block` | Rich text content |
| `image-text-block` | Image + text side-by-side |
| `card-grid-block` | Card grid layout |
| `cta-block` | Call-to-action section |
| `posts-list-block` | Blog/news listing |
| `testimonials-block` | Customer testimonials |
| `faq-block` | FAQ accordion (with JSON-LD) |
| `newsletter-block` | Newsletter signup form |
| `contact-form-block` | Contact form |
| `video-block` | Video embed |
| `favorites-block` | Affiliate products (BlogWoman) |
| `series-block` | YouTube series overview (BlogWoman) |
| `series-detail-block` | Series hero (BlogWoman) |
| `video-embed-block` | Privacy-mode video embed |
**Block Renderer pattern:**
```typescript
// src/components/blocks/index.tsx
export function BlockRenderer({ blocks }) {
return blocks.map(block => {
const Component = blockComponents[block.blockType]
return Component ? <Component {...block} /> : null
})
}
```
### BlogWoman-Specific Collections
Beyond standard CMS collections (pages, posts, testimonials), BlogWoman uses:
- **favorites** - Affiliate products with categories (fashion, beauty, travel, tech, home) and badges (investment-piece, daily-driver, grfi-approved, new, bestseller)
- **series** - YouTube series with branding (logo, cover image, brand color)
### API Endpoints Reference
| Endpoint | Purpose |
|----------|---------|
| `GET /api/pages?where[tenant][equals]=9` | Fetch pages |
| `GET /api/posts?where[tenant][equals]=9` | Fetch blog posts |
| `GET /api/navigations?where[tenant][equals]=9&where[type][equals]=header` | Get navigation |
| `GET /api/site-settings?where[tenant][equals]=9` | Site configuration |
| `GET /api/favorites?where[tenant][equals]=9` | Affiliate products |
| `GET /api/series?where[tenant][equals]=9` | YouTube series |
| `POST /api/newsletter/subscribe` | Newsletter signup (body: email, tenantId, source) |
| `POST /api/form-submissions` | Contact form submission |
**API Documentation:** https://cms.c2sgmbh.de/api/docs (Swagger UI)
## Design System (Styleguide)
**Philosophy:** "Editorial Warmth" - Premium but approachable, like a lifestyle magazine.
### Colors (60/30/10 Rule)
| Name | Hex | Use |
|------|-----|-----|
| Ivory | `#F7F3EC` | Backgrounds (60%) |
| Sand | `#C6A47E` | Cards, modules (30%) |
| Espresso | `#2B2520` | Text, headlines |
| Brass | `#B08D57` | Primary buttons, highlights (10%) |
| Bordeaux | `#6B1F2B` | Accent for P&L series |
| Rosé | `#D4A5A5` | SPARK series accent |
| Gold | `#C9A227` | Premium badges |
| Soft White | `#FBF8F3` | Cards, surfaces |
| Warm Gray | `#DDD4C7` | Borders |
**Forbidden:** Neon colors, pure black (#000), pure white (#FFF), cold blue.
### Typography
- **Headlines:** Playfair Display (600 weight)
- **Body/UI:** Inter (400, 500, 600)
- **NO CAPSLOCK HEADLINES** - use uppercase only for small labels with letter-spacing
### Series Pills (BlogWoman)
YouTube series use color-coded pills:
- GRFI: Sand background
- Investment: Brass background
- P&L: Bordeaux background
- SPARK: Rosé background
- Inner Circle: Gold background
## SEO Requirements
1. **Meta tags** from page.meta field
2. **JSON-LD** for Organization, FAQPage, Article schemas
3. **Open Graph** tags for social sharing
4. Sitemap available at: https://cms.c2sgmbh.de/sitemap.xml
## Analytics (Umami)
Umami is cookieless and DSGVO-compliant - runs without consent banner.
```typescript
// Track custom events
import { useAnalytics } from '@/hooks/useAnalytics'
const { trackEvent, trackNewsletterSubscribe, trackAffiliateClick } = useAnalytics()
```
## Documentation References
| Document | Path |
|----------|------|
| Styleguide | `/docs/guides/styleguide.md` |
| Universal Features | `/docs/architecture/UNIVERSAL_FEATURES.md` |
| API Guide | `/docs/api/API_ANLEITUNG.md` |
| SEO | `/docs/guides/SEO_ERWEITERUNG.md` |
| Analytics | `/docs/architecture/Analytics.md` |
| Frontend Guide | `/docs/guides/FRONTEND.md` |
| Development Prompt | `/prompts/2026-01-20_blogwoman-frontend-entwicklung.md` |
## Import Alias
`@/*` maps to `./src/*` (configured in tsconfig.json)

View file

@ -9,9 +9,11 @@
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1"
"react-dom": "19.2.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
clsx:
specifier: ^2.1.1
version: 2.1.1
next:
specifier: 16.0.10
version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@ -17,6 +20,9 @@ importers:
react-dom:
specifier: 19.2.1
version: 19.2.1(react@19.2.1)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@ -799,6 +805,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -1785,6 +1795,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
@ -2649,6 +2662,8 @@ snapshots:
client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -3830,6 +3845,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tailwind-merge@3.4.0: {}
tailwindcss@4.1.18: {}
tapable@2.3.0: {}

88
src/app/[slug]/page.tsx Normal file
View file

@ -0,0 +1,88 @@
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPage, getPages, getSeoSettings } from '@/lib/api'
import { BlockRenderer } from '@/components/blocks'
import { generateBreadcrumbSchema } from '@/lib/structuredData'
interface PageProps {
params: Promise<{ slug: string }>
}
export async function generateStaticParams() {
try {
const pages = await getPages()
return pages.docs
.filter((page) => page.slug !== 'home')
.map((page) => ({
slug: page.slug,
}))
} catch {
// Return empty array if API is unavailable during build
return []
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const [page, seoSettings] = await Promise.all([
getPage(slug),
getSeoSettings(),
])
if (!page) {
return {}
}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = page.meta?.title || page.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description =
page.meta?.description || seoSettings?.metaDefaults?.defaultDescription
const image =
page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url
return {
title,
description,
openGraph: {
title,
description: description || undefined,
images: image ? [{ url: image }] : undefined,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title,
description: description || undefined,
images: image ? [image] : undefined,
},
robots: {
index: !page.meta?.noIndex,
follow: !page.meta?.noFollow,
},
}
}
export default async function DynamicPage({ params }: PageProps) {
const { slug } = await params
const page = await getPage(slug)
if (!page) {
notFound()
}
const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' },
{ name: page.title, url: `/${slug}` },
])
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
/>
<BlockRenderer blocks={page.layout} />
</>
)
}

View file

@ -0,0 +1,109 @@
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 { RichTextRenderer } from '@/components/blocks'
import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
interface AnnouncementPostPageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: AnnouncementPostPageProps): Promise<Metadata> {
const { slug } = await params
const [post, seoSettings] = await Promise.all([
getPost(slug),
getSeoSettings(),
])
if (!post) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = post.meta?.title || post.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description =
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
const image =
post.meta?.image?.url ||
post.featuredImage?.url ||
seoSettings?.metaDefaults?.defaultImage?.url
return {
title,
description,
openGraph: {
title,
description: description || undefined,
images: image ? [{ url: image }] : undefined,
type: 'article',
},
twitter: {
card: 'summary_large_image',
title,
description: description || undefined,
images: image ? [image] : undefined,
},
robots: {
index: !post.meta?.noIndex,
follow: !post.meta?.noFollow,
},
}
}
export default async function AnnouncementPostPage({ params }: AnnouncementPostPageProps) {
const { slug } = await params
const [post, settings] = await Promise.all([
getPost(slug),
getSiteSettings(),
])
if (!post) {
notFound()
}
const imageUrl = getImageUrl(post.featuredImage)
const blogSchema = generateBlogPostingSchema(post, settings)
const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' },
{ name: 'Aktuelles', url: '/aktuelles' },
{ name: post.title, url: `/aktuelles/${post.slug}` },
])
return (
<article className="py-12 md:py-16">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([blogSchema, breadcrumbSchema]),
}}
/>
<div className="container max-w-3xl">
<header className="mb-10 text-center">
<p className="text-sm text-warm-gray-dark mb-3">
{post.publishedAt ? formatDate(post.publishedAt) : formatDate(post.createdAt)}
</p>
<h1 className="mb-6">{post.title}</h1>
{post.excerpt && (
<p className="text-lg text-espresso/80">{post.excerpt}</p>
)}
</header>
{imageUrl && (
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image
src={imageUrl}
alt={post.featuredImage?.alt || post.title}
fill
className="object-cover"
priority
/>
</div>
)}
{post.content && <RichTextRenderer content={post.content} />}
</div>
</article>
)
}

View file

@ -0,0 +1,109 @@
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 { RichTextRenderer } from '@/components/blocks'
import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
interface BlogPostPageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
const { slug } = await params
const [post, seoSettings] = await Promise.all([
getPost(slug),
getSeoSettings(),
])
if (!post) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = post.meta?.title || post.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description =
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
const image =
post.meta?.image?.url ||
post.featuredImage?.url ||
seoSettings?.metaDefaults?.defaultImage?.url
return {
title,
description,
openGraph: {
title,
description: description || undefined,
images: image ? [{ url: image }] : undefined,
type: 'article',
},
twitter: {
card: 'summary_large_image',
title,
description: description || undefined,
images: image ? [image] : undefined,
},
robots: {
index: !post.meta?.noIndex,
follow: !post.meta?.noFollow,
},
}
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params
const [post, settings] = await Promise.all([
getPost(slug),
getSiteSettings(),
])
if (!post) {
notFound()
}
const imageUrl = getImageUrl(post.featuredImage)
const blogSchema = generateBlogPostingSchema(post, settings)
const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' },
{ name: 'Blog', url: '/blog' },
{ name: post.title, url: `/blog/${post.slug}` },
])
return (
<article className="py-12 md:py-16">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([blogSchema, breadcrumbSchema]),
}}
/>
<div className="container max-w-3xl">
<header className="mb-10 text-center">
<p className="text-sm text-warm-gray-dark mb-3">
{post.publishedAt ? formatDate(post.publishedAt) : formatDate(post.createdAt)}
</p>
<h1 className="mb-6">{post.title}</h1>
{post.excerpt && (
<p className="text-lg text-espresso/80">{post.excerpt}</p>
)}
</header>
{imageUrl && (
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image
src={imageUrl}
alt={post.featuredImage?.alt || post.title}
fill
className="object-cover"
priority
/>
</div>
)}
{post.content && <RichTextRenderer content={post.content} />}
</div>
</article>
)
}

175
src/app/blog/page.tsx Normal file
View file

@ -0,0 +1,175 @@
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'
export const metadata: Metadata = {
title: 'Blog',
description: 'Alle Artikel und Beiträge auf BlogWoman',
}
export default async function BlogPage() {
const postsData = await getPosts({ limit: 24 })
const posts = postsData.docs
if (!posts || posts.length === 0) {
return (
<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>
</div>
</section>
)
}
const [featured, ...rest] = posts
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} />
))}
</div>
</div>
</section>
</>
)
}
function FeaturedPostCard({ post }: { post: Post }) {
const imageUrl = getImageUrl(post.featuredImage)
const series = post.series as Series | undefined
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
/>
)}
</div>
{/* Content */}
<div>
<div className="flex items-center gap-3 mb-4">
{series && (
<SeriesPill series={series.slug}>{series.title}</SeriesPill>
)}
<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>
)}
<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>
</span>
</div>
</article>
</Link>
)
}
function PostCard({ post }: { post: Post }) {
const imageUrl = getImageUrl(post.featuredImage)
const series = post.series as Series | undefined
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"
/>
)}
</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"
>
{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>
)}
</div>
</article>
</Link>
)
}

View file

@ -0,0 +1,93 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
interface ComingSoonWrapperProps {
children: React.ReactNode
}
export function ComingSoonWrapper({ children }: ComingSoonWrapperProps) {
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
// Trigger entrance animation after mount
const timer = setTimeout(() => setIsLoaded(true), 100)
return () => clearTimeout(timer)
}, [])
return (
<div
className={cn(
'coming-soon-page min-h-screen',
'transition-opacity duration-700 ease-out',
isLoaded ? 'opacity-100' : 'opacity-0'
)}
>
{/* Subtle animated background texture */}
<div
className="fixed inset-0 pointer-events-none z-0 opacity-[0.03]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%232B2520' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}
/>
{/* Content */}
<div className="relative z-10">{children}</div>
{/* Decorative corner flourishes */}
<div className="fixed top-0 left-0 w-32 h-32 pointer-events-none z-20 opacity-20">
<svg
viewBox="0 0 100 100"
className="w-full h-full text-brass"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
>
<path d="M0 50 Q 25 25, 50 0" />
<path d="M0 70 Q 35 35, 70 0" />
<path d="M0 90 Q 45 45, 90 0" />
</svg>
</div>
<div className="fixed top-0 right-0 w-32 h-32 pointer-events-none z-20 opacity-20 rotate-90">
<svg
viewBox="0 0 100 100"
className="w-full h-full text-brass"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
>
<path d="M0 50 Q 25 25, 50 0" />
<path d="M0 70 Q 35 35, 70 0" />
<path d="M0 90 Q 45 45, 90 0" />
</svg>
</div>
<div className="fixed bottom-0 left-0 w-32 h-32 pointer-events-none z-20 opacity-20 -rotate-90">
<svg
viewBox="0 0 100 100"
className="w-full h-full text-brass"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
>
<path d="M0 50 Q 25 25, 50 0" />
<path d="M0 70 Q 35 35, 70 0" />
<path d="M0 90 Q 45 45, 90 0" />
</svg>
</div>
<div className="fixed bottom-0 right-0 w-32 h-32 pointer-events-none z-20 opacity-20 rotate-180">
<svg
viewBox="0 0 100 100"
className="w-full h-full text-brass"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
>
<path d="M0 50 Q 25 25, 50 0" />
<path d="M0 70 Q 35 35, 70 0" />
<path d="M0 90 Q 45 45, 90 0" />
</svg>
</div>
</div>
)
}

View file

@ -0,0 +1,59 @@
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPage, getSiteSettings } from '@/lib/api'
import { BlockRenderer } from '@/components/blocks'
import { ComingSoonWrapper } from './ComingSoonWrapper'
export async function generateMetadata(): Promise<Metadata> {
const [page, settings] = await Promise.all([
getPage('coming-soon'),
getSiteSettings(),
])
if (!page) {
return {
title: 'Kommt bald',
}
}
const title = page.meta?.title || page.title || 'Kommt bald'
const description =
page.meta?.description ||
`${settings?.siteName || 'BlogWoman'} - Kommt bald. Sei dabei, wenn es losgeht.`
return {
title,
description,
openGraph: {
title,
description,
type: 'website',
locale: 'de_DE',
siteName: settings?.siteName || 'BlogWoman',
images: page.meta?.image?.url ? [{ url: page.meta.image.url }] : undefined,
},
twitter: {
card: 'summary_large_image',
title,
description,
},
robots: {
index: true,
follow: true,
},
}
}
export default async function ComingSoonPage() {
const page = await getPage('coming-soon')
if (!page) {
notFound()
}
return (
<ComingSoonWrapper>
<BlockRenderer blocks={page.layout} />
</ComingSoonWrapper>
)
}

238
src/app/favoriten/page.tsx Normal file
View file

@ -0,0 +1,238 @@
import { Metadata } from 'next'
import Image from 'next/image'
import { getFavorites } from '@/lib/api'
import { getImageUrl } from '@/lib/utils'
import { Badge } from '@/components/ui'
import type { Favorite } from '@/lib/types'
export const metadata: Metadata = {
title: 'Favoriten',
description: 'Meine liebsten Produkte und Empfehlungen',
}
const badgeLabels: Record<string, string> = {
'investment-piece': 'Investment Piece',
'daily-driver': 'Daily Driver',
'grfi-approved': 'GRFI Approved',
new: 'Neu',
bestseller: 'Bestseller',
}
const categoryLabels: Record<string, string> = {
fashion: 'Mode',
beauty: 'Beauty',
lifestyle: 'Lifestyle',
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 categories = Object.keys(groupedFavorites)
if (!favorites || favorites.length === 0) {
return (
<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>
</div>
</section>
)
}
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.
</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"
>
{categoryLabels[category] || category}
</a>
))}
</div>
</div>
</nav>
)}
{/* Favorites by Category */}
{categories.map((category) => (
<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>
<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} />
))}
</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>
<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.
</p>
</div>
</div>
</div>
</section>
</>
)
}
function FavoriteCard({ favorite }: { favorite: Favorite }) {
const imageUrl = getImageUrl(favorite.image)
return (
<a
href={favorite.affiliateUrl}
target="_blank"
rel="noopener noreferrer sponsored"
className="group block"
>
<article className="bg-soft-white border border-warm-gray rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-xl 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 */}
{favorite.badge && (
<div className="absolute top-3 left-3">
<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 */}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-warm-gray">
{favorite.price && (
<span className="font-semibold text-espresso">
{favorite.price}
</span>
)}
<span className="inline-flex items-center gap-1 text-sm font-medium text-brass group-hover:gap-2 transition-all">
Ansehen
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
</span>
</div>
</div>
</article>
</a>
)
}

View file

@ -1,26 +1,449 @@
@import "tailwindcss";
/* ============================================
BlogWoman Design System
Philosophy: "Editorial Warmth"
============================================ */
:root {
--background: #ffffff;
--foreground: #171717;
/* Primary Colors (60/30/10 Rule) */
--color-ivory: #F7F3EC;
--color-sand: #C6A47E;
--color-espresso: #2B2520;
/* Accent Colors (10%) */
--color-brass: #B08D57;
--color-brass-hover: #9E7E4D;
--color-bordeaux: #6B1F2B;
--color-rose: #D4A5A5;
--color-gold: #C9A227;
/* Neutral Colors */
--color-soft-white: #FBF8F3;
--color-warm-gray: #DDD4C7;
--color-warm-gray-dark: #B8ADA0;
/* Functional Colors */
--color-success: #4A7C59;
--color-warning: #D4A574;
--color-error: #8B3A3A;
--color-info: #6B8E9B;
/* Semantic Aliases */
--color-background: var(--color-ivory);
--color-surface: var(--color-soft-white);
--color-text-primary: var(--color-espresso);
--color-text-secondary: var(--color-warm-gray-dark);
--color-border: var(--color-warm-gray);
--color-primary: var(--color-brass);
--color-primary-hover: var(--color-brass-hover);
/* Typography */
--font-headline: 'Playfair Display', Georgia, serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* Font Sizes */
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-md: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.125rem;
--text-4xl: 2.75rem;
--text-5xl: 3.5rem;
/* Font Weights */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Line Heights */
--leading-tight: 1.15;
--leading-snug: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
--leading-loose: 1.7;
/* Spacing (8px base) */
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
--space-32: 8rem;
/* Border Radius */
--radius-none: 0;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-3xl: 24px;
--radius-full: 9999px;
/* Shadows (warm-tinted based on Espresso) */
--shadow-sm: 0 1px 2px rgba(43, 37, 32, 0.05);
--shadow-md: 0 4px 12px rgba(43, 37, 32, 0.08);
--shadow-lg: 0 8px 24px rgba(43, 37, 32, 0.1);
--shadow-xl: 0 12px 40px rgba(43, 37, 32, 0.12);
--shadow-2xl: 0 20px 60px rgba(43, 37, 32, 0.15);
/* Transitions */
--ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1);
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
/* Container */
--container-sm: 640px;
--container-md: 768px;
--container-lg: 1024px;
--container-xl: 1200px;
--container-2xl: 1400px;
--content-max-width: 1200px;
--content-padding: var(--space-5);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
@media (min-width: 768px) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--content-padding: var(--space-8);
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@media (min-width: 1024px) {
:root {
--content-padding: var(--space-10);
}
}
/* Tailwind CSS 4 Theme Extension */
@theme inline {
/* Colors */
--color-ivory: var(--color-ivory);
--color-sand: var(--color-sand);
--color-espresso: var(--color-espresso);
--color-brass: var(--color-brass);
--color-brass-hover: var(--color-brass-hover);
--color-bordeaux: var(--color-bordeaux);
--color-rose: var(--color-rose);
--color-gold: var(--color-gold);
--color-soft-white: var(--color-soft-white);
--color-warm-gray: var(--color-warm-gray);
--color-warm-gray-dark: var(--color-warm-gray-dark);
--color-success: var(--color-success);
--color-warning: var(--color-warning);
--color-error: var(--color-error);
--color-info: var(--color-info);
--color-background: var(--color-background);
--color-surface: var(--color-surface);
--color-primary: var(--color-primary);
--color-primary-hover: var(--color-primary-hover);
/* Fonts */
--font-headline: var(--font-headline);
--font-body: var(--font-body);
/* Border Radius */
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-2xl: var(--radius-2xl);
--radius-3xl: var(--radius-3xl);
--radius-full: var(--radius-full);
/* Shadows */
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
/* Base Styles */
body {
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--font-normal);
line-height: var(--leading-relaxed);
color: var(--color-text-primary);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Headlines - Playfair Display */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-headline);
font-weight: var(--font-semibold);
color: var(--color-espresso);
margin: 0;
}
h1 {
font-size: 2.25rem;
line-height: var(--leading-tight);
letter-spacing: -0.02em;
}
h2 {
font-size: 1.75rem;
line-height: 1.2;
letter-spacing: -0.02em;
}
h3 {
font-size: 1.5rem;
line-height: var(--leading-snug);
letter-spacing: -0.01em;
}
h4 {
font-size: 1.25rem;
font-weight: var(--font-medium);
line-height: 1.3;
}
h5 {
font-size: 1.125rem;
font-weight: var(--font-medium);
line-height: 1.35;
}
h6 {
font-size: 1rem;
font-weight: var(--font-medium);
line-height: 1.4;
}
/* Responsive Typography */
@media (min-width: 768px) {
h1 { font-size: 2.75rem; }
h2 { font-size: 2.25rem; }
h3 { font-size: 1.75rem; }
h4 { font-size: 1.5rem; }
}
@media (min-width: 1024px) {
h1 { font-size: 3.5rem; }
h2 { font-size: 2.75rem; }
h3 { font-size: 2.125rem; }
h4 { font-size: 1.75rem; }
}
p {
margin: 0 0 1em 0;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
height: auto;
display: block;
}
/* Focus States */
:focus-visible {
outline: 2px solid var(--color-brass);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
/* Screen Reader Only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Container Utility */
.container {
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding-left: var(--content-padding);
padding-right: var(--content-padding);
}
/* ============================================
Animations
============================================ */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideInFromLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInFromRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Animation utility classes */
.animate-fade-in-up {
animation: fadeInUp 0.6s var(--ease-out) forwards;
}
.animate-fade-in {
animation: fadeIn 0.5s var(--ease-out) forwards;
}
.animate-scale-in {
animation: scaleIn 0.5s var(--ease-out) forwards;
}
.animate-slide-in-left {
animation: slideInFromLeft 0.6s var(--ease-out) forwards;
}
.animate-slide-in-right {
animation: slideInFromRight 0.6s var(--ease-out) forwards;
}
/* Stagger animation delays */
.delay-100 { animation-delay: 100ms; }
.delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; }
.delay-400 { animation-delay: 400ms; }
.delay-500 { animation-delay: 500ms; }
.delay-600 { animation-delay: 600ms; }
.delay-700 { animation-delay: 700ms; }
/* ============================================
Coming Soon Page Specific Styles
============================================ */
.coming-soon-page {
/* Subtle warm gradient overlay */
background: linear-gradient(
180deg,
var(--color-ivory) 0%,
var(--color-soft-white) 50%,
var(--color-ivory) 100%
);
}
/* Enhanced hero for Coming Soon */
.coming-soon-page section:first-child {
min-height: 100svh;
}
/* Newsletter block enhancement for Coming Soon */
.coming-soon-page [class*="newsletter"] {
position: relative;
}
.coming-soon-page [class*="newsletter"]::before {
content: '';
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
var(--color-brass) 50%,
transparent 100%
);
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View file

@ -2,7 +2,7 @@ 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 { getSiteSettings, getNavigation } from '@/lib/api'
import { getSeoSettings, getSiteSettings, getNavigation } from '@/lib/api'
import './globals.css'
const playfair = Playfair_Display({
@ -18,26 +18,50 @@ const inter = Inter({
})
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSiteSettings()
const [settings, seoSettings] = await Promise.all([
getSiteSettings(),
getSeoSettings(),
])
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
const verificationOther: Record<string, string> = {}
if (seoSettings?.verification?.bing) {
verificationOther['msvalidate.01'] = seoSettings.verification.bing
}
if (seoSettings?.verification?.yandex) {
verificationOther['yandex-verification'] = seoSettings.verification.yandex
}
return {
title: {
default: settings?.siteName || 'BlogWoman',
template: `%s | ${settings?.siteName || 'BlogWoman'}`,
template: `%s ${titleSuffix || `| ${settings?.siteName || 'BlogWoman'}`}`,
},
description: settings?.siteDescription || 'Lifestyle-Blog für moderne Frauen',
description: defaultDescription,
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://blogwoman.de'),
openGraph: {
type: 'website',
locale: 'de_DE',
siteName: settings?.siteName || 'BlogWoman',
images: defaultImage ? [{ url: defaultImage }] : undefined,
},
twitter: {
card: 'summary_large_image',
images: defaultImage ? [defaultImage] : undefined,
},
robots: {
index: true,
follow: true,
index: canIndex,
follow: canIndex,
},
verification: {
google: seoSettings?.verification?.google,
other: Object.keys(verificationOther).length ? verificationOther : undefined,
},
}
}

View file

@ -0,0 +1,109 @@
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 { RichTextRenderer } from '@/components/blocks'
import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
interface NewsPostPageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: NewsPostPageProps): Promise<Metadata> {
const { slug } = await params
const [post, seoSettings] = await Promise.all([
getPost(slug),
getSeoSettings(),
])
if (!post) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = post.meta?.title || post.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description =
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
const image =
post.meta?.image?.url ||
post.featuredImage?.url ||
seoSettings?.metaDefaults?.defaultImage?.url
return {
title,
description,
openGraph: {
title,
description: description || undefined,
images: image ? [{ url: image }] : undefined,
type: 'article',
},
twitter: {
card: 'summary_large_image',
title,
description: description || undefined,
images: image ? [image] : undefined,
},
robots: {
index: !post.meta?.noIndex,
follow: !post.meta?.noFollow,
},
}
}
export default async function NewsPostPage({ params }: NewsPostPageProps) {
const { slug } = await params
const [post, settings] = await Promise.all([
getPost(slug),
getSiteSettings(),
])
if (!post) {
notFound()
}
const imageUrl = getImageUrl(post.featuredImage)
const blogSchema = generateBlogPostingSchema(post, settings)
const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' },
{ name: 'News', url: '/news' },
{ name: post.title, url: `/news/${post.slug}` },
])
return (
<article className="py-12 md:py-16">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([blogSchema, breadcrumbSchema]),
}}
/>
<div className="container max-w-3xl">
<header className="mb-10 text-center">
<p className="text-sm text-warm-gray-dark mb-3">
{post.publishedAt ? formatDate(post.publishedAt) : formatDate(post.createdAt)}
</p>
<h1 className="mb-6">{post.title}</h1>
{post.excerpt && (
<p className="text-lg text-espresso/80">{post.excerpt}</p>
)}
</header>
{imageUrl && (
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image
src={imageUrl}
alt={post.featuredImage?.alt || post.title}
fill
className="object-cover"
priority
/>
</div>
)}
{post.content && <RichTextRenderer content={post.content} />}
</div>
</article>
)
}

View file

@ -1,65 +1,71 @@
import Image from "next/image";
import { Metadata } from 'next'
import { getPage, getSeoSettings, getSiteSettings } from '@/lib/api'
import { BlockRenderer } from '@/components/blocks'
import { notFound } from 'next/navigation'
import { generateOrganizationSchema, generateWebSiteSchema } from '@/lib/structuredData'
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
export async function generateMetadata(): Promise<Metadata> {
const [page, seoSettings] = await Promise.all([
getPage('home'),
getSeoSettings(),
])
if (!page) {
return {}
}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = page.meta?.title || page.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description =
page.meta?.description || seoSettings?.metaDefaults?.defaultDescription
const image =
page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url
return {
title,
description,
openGraph: {
title,
description: description || undefined,
images: image ? [{ url: image }] : undefined,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title,
description: description || undefined,
images: image ? [image] : undefined,
},
robots: {
index: !page.meta?.noIndex,
follow: !page.meta?.noFollow,
},
}
}
export default async function HomePage() {
const [page, settings] = await Promise.all([
getPage('home'),
getSiteSettings(),
])
if (!page) {
notFound()
}
const webSiteSchema = generateWebSiteSchema(settings)
const orgSchema = generateOrganizationSchema(settings)
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([webSiteSchema, orgSchema]),
}}
/>
<BlockRenderer blocks={page.layout} />
</>
)
}

View file

@ -0,0 +1,109 @@
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 { RichTextRenderer } from '@/components/blocks'
import { generateBlogPostingSchema, generateBreadcrumbSchema } from '@/lib/structuredData'
interface PressPostPageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: PressPostPageProps): Promise<Metadata> {
const { slug } = await params
const [post, seoSettings] = await Promise.all([
getPost(slug),
getSeoSettings(),
])
if (!post) return {}
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
const titleBase = post.meta?.title || post.title
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
const description =
post.meta?.description || post.excerpt || seoSettings?.metaDefaults?.defaultDescription
const image =
post.meta?.image?.url ||
post.featuredImage?.url ||
seoSettings?.metaDefaults?.defaultImage?.url
return {
title,
description,
openGraph: {
title,
description: description || undefined,
images: image ? [{ url: image }] : undefined,
type: 'article',
},
twitter: {
card: 'summary_large_image',
title,
description: description || undefined,
images: image ? [image] : undefined,
},
robots: {
index: !post.meta?.noIndex,
follow: !post.meta?.noFollow,
},
}
}
export default async function PressPostPage({ params }: PressPostPageProps) {
const { slug } = await params
const [post, settings] = await Promise.all([
getPost(slug),
getSiteSettings(),
])
if (!post) {
notFound()
}
const imageUrl = getImageUrl(post.featuredImage)
const blogSchema = generateBlogPostingSchema(post, settings)
const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' },
{ name: 'Presse', url: '/presse' },
{ name: post.title, url: `/presse/${post.slug}` },
])
return (
<article className="py-12 md:py-16">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([blogSchema, breadcrumbSchema]),
}}
/>
<div className="container max-w-3xl">
<header className="mb-10 text-center">
<p className="text-sm text-warm-gray-dark mb-3">
{post.publishedAt ? formatDate(post.publishedAt) : formatDate(post.createdAt)}
</p>
<h1 className="mb-6">{post.title}</h1>
{post.excerpt && (
<p className="text-lg text-espresso/80">{post.excerpt}</p>
)}
</header>
{imageUrl && (
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image
src={imageUrl}
alt={post.featuredImage?.alt || post.title}
fill
className="object-cover"
priority
/>
</div>
)}
{post.content && <RichTextRenderer content={post.content} />}
</div>
</article>
)
}

22
src/app/robots.ts Normal file
View file

@ -0,0 +1,22 @@
import type { MetadataRoute } from 'next'
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://blogwoman.de'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin', '/admin/*', '/api/*', '/_next/*', '/media/*'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/admin', '/api'],
},
],
host: SITE_URL,
sitemap: `${SITE_URL}/sitemap.xml`,
}
}

View file

@ -0,0 +1,133 @@
import Image from 'next/image'
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 { RichTextRenderer } from '@/components/blocks'
import { generateBreadcrumbSchema, generateSeriesSchema } from '@/lib/structuredData'
interface SeriesPageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: SeriesPageProps): Promise<Metadata> {
const { slug } = await params
const [series, seoSettings] = await Promise.all([
getSeriesBySlug(slug),
getSeoSettings(),
])
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 image =
series.meta?.image?.url ||
series.coverImage?.url ||
series.logo?.url ||
seoSettings?.metaDefaults?.defaultImage?.url
return {
title,
description,
openGraph: {
title,
description: description || undefined,
images: image ? [{ url: image }] : undefined,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title,
description: description || undefined,
images: image ? [image] : undefined,
},
robots: {
index: !series.meta?.noIndex,
follow: !series.meta?.noFollow,
},
}
}
export default async function SeriesPage({ params }: SeriesPageProps) {
const { slug } = await params
const series = await getSeriesBySlug(slug)
if (!series) {
notFound()
}
const coverUrl = getImageUrl(series.coverImage) || getImageUrl(series.logo)
const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Startseite', url: '/' },
{ name: 'Serien', url: '/serien' },
{ name: series.title, url: `/serien/${series.slug}` },
])
const seriesSchema = generateSeriesSchema(series)
return (
<article className="py-12 md:py-16">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([seriesSchema, breadcrumbSchema]),
}}
/>
<div className="container max-w-4xl">
<header className="text-center mb-10">
<h1 className="mb-4">{series.title}</h1>
{series.description && (
<div className="text-lg text-espresso/80 max-w-2xl mx-auto">
<RichTextRenderer content={series.description} />
</div>
)}
</header>
{coverUrl && (
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden bg-warm-gray mb-10">
<Image
src={coverUrl}
alt={series.title}
fill
className="object-cover"
priority
/>
</div>
)}
{series.youtubePlaylistId && (
<div className="text-center">
<Link
href={`https://www.youtube.com/playlist?list=${series.youtubePlaylistId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-brass font-medium hover:gap-3 transition-all"
>
Zur YouTube-Playlist
<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>
</Link>
</div>
)}
</div>
</article>
)
}

125
src/app/serien/page.tsx Normal file
View file

@ -0,0 +1,125 @@
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 { RichTextRenderer } from '@/components/blocks'
import type { Series } from '@/lib/types'
export const metadata: Metadata = {
title: 'Serien',
description: 'Alle YouTube-Serien und Formate auf BlogWoman',
}
export default async function SerienPage() {
const seriesData = await getSeries()
const allSeries = seriesData.docs
if (!allSeries || allSeries.length === 0) {
return (
<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>
</div>
</section>
)
}
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.
</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} />
))}
</div>
</div>
</section>
</>
)
}
function SeriesCard({ series }: { series: Series }) {
const imageUrl = getImageUrl(series.coverImage) || getImageUrl(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 */}
<div className="relative z-10 p-8">
{/* Logo */}
{series.logo && (
<div className="relative w-32 h-14 mb-4">
<Image
src={series.logo.url}
alt=""
fill
className="object-contain object-left"
/>
</div>
)}
{/* Title */}
<h2 className="text-2xl font-semibold text-soft-white mb-3">
{series.title}
</h2>
{/* Description */}
{series.description && (
<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>
</span>
</div>
</article>
</Link>
)
}

85
src/app/sitemap.ts Normal file
View file

@ -0,0 +1,85 @@
import type { MetadataRoute } from 'next'
import { getPage, getPages, getPosts } from '@/lib/api'
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://blogwoman.de'
function buildUrl(path: string) {
return `${SITE_URL}${path.startsWith('/') ? path : `/${path}`}`
}
async function getAllPages() {
const allPages = []
let page = 1
while (true) {
const data = await getPages({ limit: 100, page })
allPages.push(...data.docs)
if (!data.hasNextPage) break
page = data.nextPage || page + 1
}
return allPages
}
async function getAllPosts() {
const allPosts = []
let page = 1
while (true) {
const data = await getPosts({ limit: 100, page })
allPosts.push(...data.docs)
if (!data.hasNextPage) break
page = data.nextPage || page + 1
}
return allPosts
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [home, pages, posts] = await Promise.all([
getPage('home'),
getAllPages(),
getAllPosts(),
])
const entries: MetadataRoute.Sitemap = []
if (home) {
entries.push({
url: buildUrl('/'),
lastModified: home.updatedAt || home.createdAt,
changeFrequency: 'daily',
priority: 1,
})
}
pages
.filter((page) => page.slug !== 'home')
.forEach((page) => {
entries.push({
url: buildUrl(`/${page.slug}`),
lastModified: page.updatedAt || page.createdAt,
changeFrequency: 'weekly',
priority: 0.8,
})
})
const postPrefix: Record<string, string> = {
blog: '/blog',
news: '/news',
press: '/presse',
announcement: '/aktuelles',
}
posts.forEach((post) => {
const prefix = postPrefix[post.type] || '/blog'
entries.push({
url: buildUrl(`${prefix}/${post.slug}`),
lastModified: post.updatedAt || post.createdAt,
changeFrequency: 'monthly',
priority: 0.6,
})
})
return entries
}

View file

@ -0,0 +1,32 @@
'use client'
import Script from 'next/script'
/**
* Umami Analytics Script Component
*
* Umami is a cookieless, GDPR-compliant analytics solution.
* No consent banner required as it doesn't use cookies or track personal data.
*
* Configure via environment variables:
* - NEXT_PUBLIC_UMAMI_WEBSITE_ID: Your Umami website ID
* - NEXT_PUBLIC_UMAMI_URL: Your Umami instance URL (defaults to cloud.umami.is)
*/
export function UmamiScript() {
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
const umamiUrl =
process.env.NEXT_PUBLIC_UMAMI_URL || 'https://cloud.umami.is/script.js'
// Don't render if no website ID is configured
if (!websiteId) {
return null
}
return (
<Script
src={umamiUrl}
data-website-id={websiteId}
strategy="afterInteractive"
/>
)
}

View file

@ -0,0 +1,2 @@
export { UmamiScript } from './UmamiScript'
export { useAnalytics } from './useAnalytics'

View file

@ -0,0 +1,90 @@
'use client'
import { useCallback } from 'react'
// Umami tracking interface
declare global {
interface Window {
umami?: {
track: (eventName: string, eventData?: Record<string, unknown>) => void
}
}
}
/**
* Custom hook for tracking analytics events with Umami
*
* Usage:
* ```tsx
* const { trackEvent } = useAnalytics()
*
* // Track a simple event
* trackEvent('button_click')
*
* // Track with additional data
* trackEvent('newsletter_signup', { source: 'footer' })
* ```
*/
export function useAnalytics() {
const trackEvent = useCallback(
(eventName: string, eventData?: Record<string, unknown>) => {
if (typeof window !== 'undefined' && window.umami) {
window.umami.track(eventName, eventData)
}
},
[]
)
// Common event tracking helpers
const trackClick = useCallback(
(elementName: string, additionalData?: Record<string, unknown>) => {
trackEvent('click', { element: elementName, ...additionalData })
},
[trackEvent]
)
const trackNavigation = useCallback(
(destination: string) => {
trackEvent('navigation', { destination })
},
[trackEvent]
)
const trackFormSubmit = useCallback(
(formName: string, success: boolean) => {
trackEvent('form_submit', { form: formName, success })
},
[trackEvent]
)
const trackExternalLink = useCallback(
(url: string, label?: string) => {
trackEvent('external_link', { url, label })
},
[trackEvent]
)
const trackVideoPlay = useCallback(
(videoTitle: string, videoId?: string) => {
trackEvent('video_play', { title: videoTitle, id: videoId })
},
[trackEvent]
)
const trackSearch = useCallback(
(query: string, resultsCount?: number) => {
trackEvent('search', { query, results: resultsCount })
},
[trackEvent]
)
return {
trackEvent,
trackClick,
trackNavigation,
trackFormSubmit,
trackExternalLink,
trackVideoPlay,
trackSearch,
}
}

View file

@ -0,0 +1,58 @@
import Image from 'next/image'
import { Button } from '@/components/ui'
import { cn } from '@/lib/utils'
import type { CTABlock as CTABlockType } from '@/lib/types'
type CTABlockProps = Omit<CTABlockType, 'blockType'>
export function CTABlock({
heading,
subheading,
buttonText,
buttonLink,
backgroundColor = 'brass',
backgroundImage,
}: CTABlockProps) {
const bgClasses = {
brass: 'bg-brass',
espresso: 'bg-espresso',
bordeaux: 'bg-bordeaux',
}
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>
)}
<div className="container relative z-10">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-soft-white mb-4">{heading}</h2>
{subheading && (
<p className="text-soft-white/80 text-lg md:text-xl mb-8">
{subheading}
</p>
)}
<Button
href={buttonLink}
variant="secondary"
size="lg"
className="border-soft-white text-soft-white hover:bg-soft-white hover:text-espresso"
>
{buttonText}
</Button>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,97 @@
import Image from 'next/image'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import type { CardGridBlock as CardGridBlockType } from '@/lib/types'
type CardGridBlockProps = Omit<CardGridBlockType, 'blockType'>
export function CardGridBlock({
title,
subtitle,
cards,
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',
}
return (
<section className="py-16 md:py-20">
<div className="container">
{/* Section Header */}
{(title || subtitle) && (
<div className="text-center max-w-2xl mx-auto mb-12">
{title && <h2 className="mb-4">{title}</h2>}
{subtitle && (
<p className="text-lg text-espresso/80">{subtitle}</p>
)}
</div>
)}
{/* Card Grid */}
<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>
{cards.map((card, index) => (
<CardItem key={index} {...card} />
))}
</div>
</div>
</section>
)
}
interface CardItemProps {
title: string
description?: string
image?: { url: string; alt?: string }
link?: string
icon?: string
}
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'
)}
>
{image?.url && (
<div className="relative aspect-video">
<Image
src={image.url}
alt={image.alt || title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
{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>
</div>
)}
<h3 className="text-xl font-semibold mb-2">{title}</h3>
{description && (
<p className="text-espresso/80">{description}</p>
)}
</div>
</div>
)
if (link) {
return (
<Link href={link} className="block">
{content}
</Link>
)
}
return content
}

View file

@ -0,0 +1,180 @@
'use client'
import { useState } from 'react'
import { Button, Input, Textarea } from '@/components/ui'
import { submitContactForm } from '@/lib/api'
import type { ContactFormBlock as ContactFormBlockType } from '@/lib/types'
type ContactFormBlockProps = Omit<ContactFormBlockType, 'blockType'>
export function ContactFormBlock({
title,
subtitle,
formId,
showName = true,
showPhone = false,
showSubject = true,
successMessage = 'Vielen Dank für Ihre Nachricht! Wir melden uns zeitnah bei Ihnen.',
}: ContactFormBlockProps) {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
subject: '',
message: '',
})
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,
}))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('loading')
setErrorMessage('')
try {
const result = await submitContactForm({
...formData,
formId,
})
if (result.success) {
setStatus('success')
setFormData({ name: '', email: '', phone: '', subject: '', message: '' })
} else {
setStatus('error')
setErrorMessage(result.message || 'Ein Fehler ist aufgetreten.')
}
} catch {
setStatus('error')
setErrorMessage('Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.')
}
}
return (
<section className="py-16 md:py-20">
<div className="container">
<div className="max-w-2xl mx-auto">
{/* Header */}
{(title || subtitle) && (
<div className="text-center mb-10">
{title && <h2 className="mb-4">{title}</h2>}
{subtitle && (
<p className="text-lg text-espresso/80">{subtitle}</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>
<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'}
/>
</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>
)}
{/* 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 */}
<div className="text-center">
<Button
type="submit"
size="lg"
disabled={status === 'loading'}
>
{status === 'loading' ? 'Wird gesendet...' : 'Nachricht senden'}
</Button>
</div>
</form>
)}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,51 @@
import Image from 'next/image'
import { cn } from '@/lib/utils'
import { RichTextRenderer } from './RichTextRenderer'
import type { ImageTextBlock as ImageTextBlockType } from '@/lib/types'
type ImageTextBlockProps = Omit<ImageTextBlockType, 'blockType'>
export function ImageTextBlock({
heading,
content,
image,
imagePosition = 'left',
backgroundColor = 'white',
}: ImageTextBlockProps) {
const bgClasses = {
white: 'bg-soft-white',
ivory: 'bg-ivory',
sand: 'bg-sand/20',
}
return (
<section className={cn('py-16 md:py-20', bgClasses[backgroundColor])}>
<div className="container">
<div
className={cn(
'grid grid-cols-1 md:grid-cols-2 gap-12 items-center',
imagePosition === 'right' && 'md:[direction:rtl]'
)}
>
{/* Image */}
<div className={cn('md:[direction:ltr]')}>
<div className="relative aspect-[4/3] rounded-2xl overflow-hidden">
<Image
src={image.url}
alt={image.alt || ''}
fill
className="object-cover"
/>
</div>
</div>
{/* Content */}
<div className="md:[direction:ltr]">
{heading && <h2 className="mb-6">{heading}</h2>}
<RichTextRenderer content={content} />
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,278 @@
import Image from 'next/image'
import Link from 'next/link'
import { cn, getImageUrl } from '@/lib/utils'
import { SeriesPill } from '@/components/ui'
import { getSeries } from '@/lib/api'
import { RichTextRenderer } from './RichTextRenderer'
import type { SeriesBlock as SeriesBlockType, Series } from '@/lib/types'
type SeriesBlockProps = Omit<SeriesBlockType, 'blockType'>
export async function SeriesBlock({
title,
subtitle,
displayMode,
selectedSeries,
layout = 'grid',
showDescription = true,
}: 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
}
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>
)}
</div>
)}
{/* Series */}
{layout === 'featured' ? (
<FeaturedLayout items={items} showDescription={showDescription} />
) : layout === 'list' ? (
<ListLayout items={items} showDescription={showDescription} />
) : (
<GridLayout items={items} showDescription={showDescription} />
)}
</div>
</section>
)
}
function GridLayout({
items,
showDescription,
}: {
items: Series[]
showDescription?: boolean
}) {
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}
/>
))}
</div>
)
}
function ListLayout({
items,
showDescription,
}: {
items: Series[]
showDescription?: boolean
}) {
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>
))}
</div>
)
}
function FeaturedLayout({
items,
showDescription,
}: {
items: Series[]
showDescription?: boolean
}) {
const [featured, ...rest] = items
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"
/>
)}
<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>
)}
</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
/>
))}
</div>
</div>
)
}
interface SeriesCardProps {
series: Series
showDescription?: boolean
compact?: boolean
}
function SeriesCard({ series, showDescription, compact }: SeriesCardProps) {
const imageUrl = getImageUrl(series.coverImage) || getImageUrl(series.logo)
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 */}
{!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>
)}
{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>
)}
{/* 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>
)}
</div>
</article>
</Link>
)
}

View file

@ -0,0 +1,130 @@
import Image from 'next/image'
import { Button } from '@/components/ui'
import { cn } from '@/lib/utils'
import { RichTextRenderer } from './RichTextRenderer'
import type { SeriesDetailBlock as SeriesDetailBlockType } from '@/lib/types'
type SeriesDetailBlockProps = Omit<SeriesDetailBlockType, 'blockType'>
export function SeriesDetailBlock({
series,
layout = 'hero',
showLogo = true,
showPlaylistLink = true,
useBrandColor = true,
}: SeriesDetailBlockProps) {
const bgColor = useBrandColor && series.brandColor ? series.brandColor : '#2B2520'
const playlistUrl = series.youtubePlaylistId
? `https://www.youtube.com/playlist?list=${series.youtubePlaylistId}`
: null
if (layout === 'compact') {
return (
<section
className="py-12"
style={{ backgroundColor: useBrandColor ? bgColor : undefined }}
>
<div className="container">
<div className="flex items-center gap-6">
{showLogo && series.logo && (
<div className="relative w-24 h-16 flex-shrink-0">
<Image
src={series.logo.url}
alt={series.title}
fill
className="object-contain"
/>
</div>
)}
<div className="flex-1">
<h1 className={cn(useBrandColor && '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'
)}
>
Playlist ansehen
</Button>
)}
</div>
</div>
</section>
)
}
// Hero 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>
{/* 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 */}
<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>
)}
{/* 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"
>
<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>
Playlist auf YouTube
</Button>
)}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,146 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import type { StatsBlock as StatsBlockType } from '@/lib/types'
type StatsBlockProps = Omit<StatsBlockType, 'blockType'>
export function StatsBlock({
title,
subtitle,
stats,
backgroundColor = 'soft-white',
layout = 'row',
}: StatsBlockProps) {
const [isVisible, setIsVisible] = useState(false)
const sectionRef = useRef<HTMLElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
},
{ threshold: 0.2 }
)
if (sectionRef.current) {
observer.observe(sectionRef.current)
}
return () => observer.disconnect()
}, [])
const bgClasses = {
ivory: 'bg-ivory',
'soft-white': 'bg-soft-white',
sand: 'bg-sand/20',
}
return (
<section
ref={sectionRef}
className={cn('py-16 md:py-24', bgClasses[backgroundColor])}
>
<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>
)}
</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'
)}
>
{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 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>
{/* 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>
{/* 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>
)}
</div>
))}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,34 @@
import { cn } from '@/lib/utils'
import type { TextBlock as TextBlockType } from '@/lib/types'
import { RichTextRenderer } from './RichTextRenderer'
type TextBlockProps = Omit<TextBlockType, 'blockType'>
export function TextBlock({
content,
alignment = 'left',
maxWidth = 'lg',
}: 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',
full: 'max-w-none',
}
return (
<section className="py-12 md:py-16">
<div className="container">
<div className={cn(alignmentClasses[alignment], maxWidthClasses[maxWidth])}>
<RichTextRenderer content={content} />
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,111 @@
'use client'
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'
type VideoEmbedBlockProps = Omit<VideoEmbedBlockType, 'blockType'>
export function VideoEmbedBlock({
videoUrl,
title,
aspectRatio = '16:9',
privacyMode = true,
autoplay = false,
showControls = true,
thumbnailImage,
}: VideoEmbedBlockProps) {
const [isPlaying, setIsPlaying] = useState(autoplay)
const videoId = extractYouTubeId(videoUrl)
const embedUrl = videoId
? privacyMode
? getPrivacyYouTubeUrl(videoId)
: `https://www.youtube.com/embed/${videoId}`
: null
const thumbnailUrl =
thumbnailImage?.url || (videoId ? getYouTubeThumbnail(videoId) : null)
const aspectClasses = {
'16:9': 'aspect-video',
'4:3': 'aspect-[4/3]',
'1:1': 'aspect-square',
'9:16': 'aspect-[9/16]',
}
if (!embedUrl) {
return null
}
// Build embed params
const params = new URLSearchParams()
if (isPlaying || autoplay) params.append('autoplay', '1')
params.append('rel', '0')
if (!showControls) params.append('controls', '0')
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>
</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>
)}
</div>
)
}

View file

@ -0,0 +1,94 @@
import { HeroBlock } from './HeroBlock'
import { TextBlock } from './TextBlock'
import { ImageTextBlock } from './ImageTextBlock'
import { CardGridBlock } from './CardGridBlock'
import { CTABlock } from './CTABlock'
import { DividerBlock } from './DividerBlock'
import { PostsListBlock } from './PostsListBlock'
import { TestimonialsBlock } from './TestimonialsBlock'
import { FAQBlock } from './FAQBlock'
import { NewsletterBlock } from './NewsletterBlock'
import { ContactFormBlock } from './ContactFormBlock'
import { VideoBlock } from './VideoBlock'
import { FavoritesBlock } from './FavoritesBlock'
import { SeriesBlock } from './SeriesBlock'
import { SeriesDetailBlock } from './SeriesDetailBlock'
import { VideoEmbedBlock } from './VideoEmbedBlock'
import { StatsBlock } from './StatsBlock'
import type { Block } from '@/lib/types'
// Map block types to components
const blockComponents: Record<string, React.ComponentType<Record<string, unknown>>> = {
'hero-block': HeroBlock as React.ComponentType<Record<string, unknown>>,
'text-block': TextBlock as React.ComponentType<Record<string, unknown>>,
'image-text-block': ImageTextBlock as React.ComponentType<Record<string, unknown>>,
'card-grid-block': CardGridBlock as React.ComponentType<Record<string, unknown>>,
'cta-block': CTABlock as React.ComponentType<Record<string, unknown>>,
'divider-block': DividerBlock as React.ComponentType<Record<string, unknown>>,
'posts-list-block': PostsListBlock as React.ComponentType<Record<string, unknown>>,
'testimonials-block': TestimonialsBlock as React.ComponentType<Record<string, unknown>>,
'faq-block': FAQBlock as React.ComponentType<Record<string, unknown>>,
'newsletter-block': NewsletterBlock as React.ComponentType<Record<string, unknown>>,
'contact-form-block': ContactFormBlock as React.ComponentType<Record<string, unknown>>,
'video-block': VideoBlock as React.ComponentType<Record<string, unknown>>,
'favorites-block': FavoritesBlock as React.ComponentType<Record<string, unknown>>,
'series-block': SeriesBlock as React.ComponentType<Record<string, unknown>>,
'series-detail-block': SeriesDetailBlock as React.ComponentType<Record<string, unknown>>,
'video-embed-block': VideoEmbedBlock as React.ComponentType<Record<string, unknown>>,
'stats-block': StatsBlock as React.ComponentType<Record<string, unknown>>,
}
interface BlockRendererProps {
blocks: Block[] | null | undefined
}
export function BlockRenderer({ blocks }: BlockRendererProps) {
if (!blocks || blocks.length === 0) {
return null
}
return (
<>
{blocks.map((block, index) => {
const Component = blockComponents[block.blockType]
if (!Component) {
if (process.env.NODE_ENV === 'development') {
console.warn(`Unknown block type: ${block.blockType}`)
}
return null
}
// Extract the block data, excluding blockType for the component props
const { blockType, ...blockProps } = block
return (
<Component
key={block.id || `${blockType}-${index}`}
{...blockProps}
/>
)
})}
</>
)
}
// Re-export individual blocks for direct use
export { HeroBlock } from './HeroBlock'
export { TextBlock } from './TextBlock'
export { ImageTextBlock } from './ImageTextBlock'
export { CardGridBlock } from './CardGridBlock'
export { CTABlock } from './CTABlock'
export { DividerBlock } from './DividerBlock'
export { PostsListBlock } from './PostsListBlock'
export { TestimonialsBlock } from './TestimonialsBlock'
export { FAQBlock } from './FAQBlock'
export { NewsletterBlock } from './NewsletterBlock'
export { ContactFormBlock } from './ContactFormBlock'
export { VideoBlock } from './VideoBlock'
export { FavoritesBlock } from './FavoritesBlock'
export { SeriesBlock } from './SeriesBlock'
export { SeriesDetailBlock } from './SeriesDetailBlock'
export { VideoEmbedBlock } from './VideoEmbedBlock'
export { StatsBlock } from './StatsBlock'
export { RichTextRenderer } from './RichTextRenderer'

View file

@ -0,0 +1,291 @@
import Link from 'next/link'
import Image from 'next/image'
import type { Navigation, SiteSettings, Address } from '@/lib/types'
// Helper to format address
function formatAddress(address: Address | string | undefined): string | null {
if (!address) return null
if (typeof address === 'string') return address
const parts: string[] = []
if (address.street) parts.push(address.street)
if (address.additionalLine) parts.push(address.additionalLine)
if (address.zip || address.city) {
parts.push([address.zip, address.city].filter(Boolean).join(' '))
}
if (address.country) parts.push(address.country)
return parts.length > 0 ? parts.join('\n') : null
}
interface FooterProps {
navigation: Navigation | null
settings: SiteSettings | null
}
export function Footer({ navigation, settings }: FooterProps) {
const currentYear = new Date().getFullYear()
return (
<footer className="bg-espresso text-soft-white">
<div className="container py-16">
{/* Main Footer Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10 mb-12">
{/* Brand Column */}
<div className="lg:col-span-1">
<Link href="/" className="inline-block mb-4">
{settings?.logo ? (
<Image
src={settings.logo.url}
alt={settings.siteName || 'BlogWoman'}
width={140}
height={35}
className="h-9 w-auto brightness-0 invert"
/>
) : (
<span className="font-headline text-2xl font-semibold">
{settings?.siteName || 'BlogWoman'}
</span>
)}
</Link>
<p className="text-sand italic text-sm">
Für Frauen, die Karriere, Familie & Stil ernst nehmen.
</p>
{/* Social Links */}
{settings?.socialLinks && (
<div className="flex gap-4 mt-6">
{settings.socialLinks.instagram && (
<a
href={settings.socialLinks.instagram}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="Instagram"
>
<SocialIcon platform="instagram" />
</a>
)}
{settings.socialLinks.youtube && (
<a
href={settings.socialLinks.youtube}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="YouTube"
>
<SocialIcon platform="youtube" />
</a>
)}
{settings.socialLinks.pinterest && (
<a
href={settings.socialLinks.pinterest}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="Pinterest"
>
<SocialIcon platform="pinterest" />
</a>
)}
{settings.socialLinks.tiktok && (
<a
href={settings.socialLinks.tiktok}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="TikTok"
>
<SocialIcon platform="tiktok" />
</a>
)}
{settings.socialLinks.facebook && (
<a
href={settings.socialLinks.facebook}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="Facebook"
>
<SocialIcon platform="facebook" />
</a>
)}
</div>
)}
</div>
{/* Navigation Columns */}
{navigation?.items && navigation.items.length > 0 && (
<>
{groupNavigationItems(navigation.items).map((group, index) => (
<div key={index}>
{group.title && (
<h3 className="text-xs font-bold tracking-[0.1em] uppercase text-brass mb-4">
{group.title}
</h3>
)}
<ul className="space-y-2">
{group.items.map((item) => (
<li key={item.id}>
<FooterLink item={item} />
</li>
))}
</ul>
</div>
))}
</>
)}
{/* Contact Column */}
{(settings?.email || settings?.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 && (
<p>
<a
href={`mailto:${settings.email}`}
className="text-soft-white hover:text-sand transition-colors"
>
{settings.email}
</a>
</p>
)}
{settings.phone && (
<p>
<a
href={`tel:${settings.phone.replace(/\s/g, '')}`}
className="text-soft-white hover:text-sand transition-colors"
>
{settings.phone}
</a>
</p>
)}
{formatAddress(settings.address) && (
<p className="text-warm-gray whitespace-pre-line">
{formatAddress(settings.address)}
</p>
)}
</address>
</div>
)}
</div>
{/* Bottom Bar */}
<div className="pt-8 border-t border-white/10 text-center text-sm text-warm-gray">
<p>
&copy; {currentYear} {settings?.siteName || 'BlogWoman'}. Alle Rechte
vorbehalten.
</p>
</div>
</div>
</footer>
)
}
function FooterLink({ item }: { item: Navigation['items'][0] }) {
const className = 'text-sm text-soft-white hover:text-sand transition-colors'
if (item.type === 'external' && item.url) {
return (
<a
href={item.url}
target={item.openInNewTab ? '_blank' : undefined}
rel={item.openInNewTab ? 'noopener noreferrer' : undefined}
className={className}
>
{item.label}
</a>
)
}
const href = item.page?.slug ? `/${item.page.slug}` : item.url || '/'
return (
<Link href={href} className={className}>
{item.label}
</Link>
)
}
// Helper to group navigation items for footer columns
function groupNavigationItems(items: Navigation['items']) {
// Simple grouping - you can customize based on your needs
const groups: { title?: string; items: Navigation['items'] }[] = []
items.forEach((item) => {
if (item.type === 'submenu' && item.children?.length) {
groups.push({
title: item.label,
items: item.children,
})
}
})
// Add remaining items as a group
const topLevelItems = items.filter(
(item) => item.type !== 'submenu' || !item.children?.length
)
if (topLevelItems.length > 0) {
groups.unshift({ items: topLevelItems })
}
return groups
}
// Social Media Icons
function SocialIcon({ platform }: { platform: string }) {
const iconClass = 'w-5 h-5'
switch (platform.toLowerCase()) {
case 'youtube':
return (
<svg className={iconClass} fill="currentColor" viewBox="0 0 24 24">
<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.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
case 'instagram':
return (
<svg className={iconClass} fill="currentColor" viewBox="0 0 24 24">
<path d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z" />
</svg>
)
case 'facebook':
return (
<svg className={iconClass} fill="currentColor" viewBox="0 0 24 24">
<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 'linkedin':
return (
<svg className={iconClass} fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</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">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
)
}
}

View file

@ -0,0 +1,78 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { Navigation } from './Navigation'
import { MobileMenu } from './MobileMenu'
import type { Navigation as NavigationType, SiteSettings } from '@/lib/types'
interface HeaderProps {
navigation: NavigationType | null
settings: SiteSettings | null
}
export function Header({ navigation, settings }: HeaderProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
return (
<header className="sticky top-0 z-50 bg-ivory/95 backdrop-blur-sm border-b border-warm-gray">
<div className="container">
<div className="flex items-center justify-between py-4">
{/* Logo */}
<Link
href="/"
className="font-headline text-2xl font-semibold text-espresso tracking-tight hover:text-brass transition-colors"
>
{settings?.logo ? (
<Image
src={settings.logo.url}
alt={settings.siteName || 'BlogWoman'}
width={160}
height={40}
className="h-10 w-auto"
/>
) : (
settings?.siteName || 'BlogWoman'
)}
</Link>
{/* Desktop Navigation */}
<div className="hidden md:block">
<Navigation items={navigation?.items || []} />
</div>
{/* Mobile Menu Toggle */}
<button
type="button"
className="md:hidden p-2 text-espresso hover:text-brass transition-colors"
onClick={() => setIsMobileMenuOpen(true)}
aria-label="Menu öffnen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
</div>
</div>
{/* Mobile Menu */}
<MobileMenu
items={navigation?.items || []}
isOpen={isMobileMenuOpen}
onClose={() => setIsMobileMenuOpen(false)}
/>
</header>
)
}

View file

@ -0,0 +1,122 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import type { NavigationItem } from '@/lib/types'
interface NavigationProps {
items: NavigationItem[]
className?: string
}
export function Navigation({ items, className }: NavigationProps) {
return (
<nav className={cn('flex items-center gap-8', className)}>
{items.map((item) => (
<NavItem key={item.id} item={item} />
))}
</nav>
)
}
interface NavItemProps {
item: NavigationItem
}
function NavItem({ item }: NavItemProps) {
const [isOpen, setIsOpen] = useState(false)
const linkClasses = cn(
'text-sm font-medium text-espresso',
'transition-colors duration-200',
'hover:text-brass'
)
// Submenu
if (item.type === 'submenu' && item.children?.length) {
return (
<div
className="relative"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<button
type="button"
className={cn(linkClasses, 'flex items-center gap-1')}
aria-expanded={isOpen}
>
{item.label}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className={cn(
'w-4 h-4 transition-transform duration-200',
isOpen && 'rotate-180'
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
{/* Dropdown */}
<div
className={cn(
'absolute top-full left-0 pt-2',
'opacity-0 invisible translate-y-2',
'transition-all duration-200',
isOpen && 'opacity-100 visible translate-y-0'
)}
>
<div className="bg-soft-white border border-warm-gray rounded-lg shadow-lg py-2 min-w-[200px]">
{item.children.map((child) => (
<NavLink
key={child.id}
item={child}
className="block px-4 py-2 text-sm text-espresso hover:bg-ivory hover:text-brass transition-colors"
/>
))}
</div>
</div>
</div>
)
}
// Regular link
return <NavLink item={item} className={linkClasses} />
}
interface NavLinkProps {
item: NavigationItem
className?: string
}
function NavLink({ item, className }: NavLinkProps) {
if (item.type === 'external' && item.url) {
return (
<a
href={item.url}
target={item.openInNewTab ? '_blank' : undefined}
rel={item.openInNewTab ? 'noopener noreferrer' : undefined}
className={className}
>
{item.label}
</a>
)
}
const href = item.page?.slug ? `/${item.page.slug}` : item.url || '/'
return (
<Link href={href} className={className}>
{item.label}
</Link>
)
}

View file

@ -0,0 +1,4 @@
export { Header } from './Header'
export { Footer } from './Footer'
export { Navigation } from './Navigation'
export { MobileMenu } from './MobileMenu'

105
src/components/ui/Input.tsx Normal file
View file

@ -0,0 +1,105 @@
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
required?: boolean
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, required, className, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className={cn(
'block mb-2 text-sm font-medium text-espresso',
required && "after:content-['*'] after:text-bordeaux after:ml-0.5"
)}
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
'w-full px-4 py-3.5',
'bg-soft-white text-espresso',
'text-base font-body',
'border-[1.5px] border-warm-gray rounded-lg',
'transition-all duration-200 ease-out',
'placeholder:text-warm-gray-dark',
'hover:border-sand',
'focus:outline-none focus:border-brass focus:shadow-[0_0_0_3px_rgba(176,141,87,0.15)]',
'disabled:bg-warm-gray disabled:cursor-not-allowed disabled:opacity-60',
error && 'border-error focus:border-error focus:shadow-[0_0_0_3px_rgba(139,58,58,0.15)]',
className
)}
{...props}
/>
{error && (
<p className="mt-2 text-sm text-error">{error}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
required?: boolean
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, required, className, id, ...props }, ref) => {
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={textareaId}
className={cn(
'block mb-2 text-sm font-medium text-espresso',
required && "after:content-['*'] after:text-bordeaux after:ml-0.5"
)}
>
{label}
</label>
)}
<textarea
ref={ref}
id={textareaId}
className={cn(
'w-full min-h-[120px] px-4 py-3.5',
'bg-soft-white text-espresso',
'text-base font-body leading-relaxed',
'border-[1.5px] border-warm-gray rounded-lg',
'transition-all duration-200 ease-out',
'placeholder:text-warm-gray-dark',
'hover:border-sand',
'focus:outline-none focus:border-brass focus:shadow-[0_0_0_3px_rgba(176,141,87,0.15)]',
'disabled:bg-warm-gray disabled:cursor-not-allowed disabled:opacity-60',
'resize-y',
error && 'border-error focus:border-error focus:shadow-[0_0_0_3px_rgba(139,58,58,0.15)]',
className
)}
{...props}
/>
{error && (
<p className="mt-2 text-sm text-error">{error}</p>
)}
</div>
)
}
)
Textarea.displayName = 'Textarea'

382
src/lib/api.ts Normal file
View file

@ -0,0 +1,382 @@
import type {
Page,
Post,
Navigation,
SiteSettings,
Favorite,
Series,
Testimonial,
FAQ,
SeoSettings,
PaginatedResponse,
FavoriteCategory,
FavoriteBadge,
} from './types'
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || 'https://cms.c2sgmbh.de'
const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID || '9'
// Shared empty paginated response for error fallbacks
const emptyPaginatedResponse = { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
interface FetchOptions {
revalidate?: number | false
tags?: string[]
}
async function fetchAPI<T>(
endpoint: string,
options: FetchOptions & { defaultValue?: T } = {}
): Promise<T> {
const { revalidate = 60, tags, defaultValue } = options
try {
const res = await fetch(`${PAYLOAD_URL}${endpoint}`, {
next: {
revalidate,
tags,
},
})
if (!res.ok) {
console.error(`API error: ${res.status} ${res.statusText} for ${endpoint}`)
if (defaultValue !== undefined) {
return defaultValue
}
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json()
} catch (error) {
console.error(`Fetch error for ${endpoint}:`, error)
if (defaultValue !== undefined) {
return defaultValue
}
throw error
}
}
// Pages
export async function getPage(
slug: string,
locale = 'de'
): Promise<Page | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[status][equals]': 'published',
locale,
depth: '2',
})
const data = await fetchAPI<PaginatedResponse<Page>>(
`/api/pages?${params}`,
{ tags: [`page-${slug}`] }
)
return data.docs[0] || null
}
export async function getPages(options: {
limit?: number
page?: number
locale?: string
} = {}): Promise<PaginatedResponse<Page>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[status][equals]': 'published',
limit: String(options.limit || 100),
page: String(options.page || 1),
locale: options.locale || 'de',
depth: '1',
})
return fetchAPI<PaginatedResponse<Page>>(`/api/pages?${params}`)
}
// Posts
export async function getPost(
slug: string,
locale = 'de'
): Promise<Post | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[status][equals]': 'published',
locale,
depth: '2',
})
const data = await fetchAPI<PaginatedResponse<Post>>(
`/api/posts?${params}`,
{ tags: [`post-${slug}`] }
)
return data.docs[0] || null
}
export async function getPosts(options: {
type?: 'blog' | 'news' | 'press' | 'announcement'
category?: string
series?: string
limit?: number
page?: number
locale?: string
featured?: boolean
} = {}): Promise<PaginatedResponse<Post>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[status][equals]': 'published',
sort: '-publishedAt',
limit: String(options.limit || 10),
page: String(options.page || 1),
locale: options.locale || 'de',
depth: '1',
})
if (options.type) {
params.append('where[type][equals]', options.type)
}
if (options.category) {
params.append('where[categories][contains]', options.category)
}
if (options.series) {
params.append('where[series][equals]', options.series)
}
if (options.featured) {
params.append('where[isFeatured][equals]', 'true')
}
return fetchAPI<PaginatedResponse<Post>>(
`/api/posts?${params}`,
{ tags: ['posts'], defaultValue: emptyPaginatedResponse as PaginatedResponse<Post> }
)
}
// Navigation
export async function getNavigation(
type: 'header' | 'footer' | 'mobile'
): Promise<Navigation | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[type][equals]': type,
depth: '2',
})
const data = await fetchAPI<PaginatedResponse<Navigation>>(
`/api/navigations?${params}`,
{
revalidate: 300,
tags: [`navigation-${type}`],
defaultValue: { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null },
}
)
return data.docs[0] || null
}
// Site Settings
export async function getSiteSettings(): Promise<SiteSettings | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
depth: '2',
})
const data = await fetchAPI<PaginatedResponse<SiteSettings>>(
`/api/site-settings?${params}`,
{
revalidate: 300,
tags: ['site-settings'],
defaultValue: { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null },
}
)
return data.docs[0] || null
}
// SEO Settings (Global)
export async function getSeoSettings(): Promise<SeoSettings | null> {
return fetchAPI<SeoSettings>('/api/globals/seo-settings', {
revalidate: 3600,
tags: ['seo-settings'],
defaultValue: null as unknown as SeoSettings,
})
}
// Testimonials
export async function getTestimonials(options: {
limit?: number
locale?: string
} = {}): Promise<PaginatedResponse<Testimonial>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
limit: String(options.limit || 10),
locale: options.locale || 'de',
depth: '1',
})
return fetchAPI<PaginatedResponse<Testimonial>>(
`/api/testimonials?${params}`,
{ tags: ['testimonials'] }
)
}
// FAQs
export async function getFAQs(options: {
category?: string
limit?: number
locale?: string
} = {}): Promise<PaginatedResponse<FAQ>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
sort: 'order',
limit: String(options.limit || 50),
locale: options.locale || 'de',
depth: '1',
})
if (options.category) {
params.append('where[category][equals]', options.category)
}
return fetchAPI<PaginatedResponse<FAQ>>(
`/api/faqs?${params}`,
{ tags: ['faqs'] }
)
}
// BlogWoman: Favorites
export async function getFavorites(options: {
category?: FavoriteCategory
badge?: FavoriteBadge
limit?: number
page?: number
locale?: string
} = {}): Promise<PaginatedResponse<Favorite>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[isActive][equals]': 'true',
limit: String(options.limit || 12),
page: String(options.page || 1),
locale: options.locale || 'de',
depth: '1',
})
if (options.category) {
params.append('where[category][equals]', options.category)
}
if (options.badge) {
params.append('where[badge][equals]', options.badge)
}
return fetchAPI<PaginatedResponse<Favorite>>(
`/api/favorites?${params}`,
{ tags: ['favorites'], defaultValue: emptyPaginatedResponse as PaginatedResponse<Favorite> }
)
}
export async function getFavorite(
slug: string,
locale = 'de'
): Promise<Favorite | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[isActive][equals]': 'true',
locale,
depth: '1',
})
const data = await fetchAPI<PaginatedResponse<Favorite>>(
`/api/favorites?${params}`,
{ tags: [`favorite-${slug}`] }
)
return data.docs[0] || null
}
// BlogWoman: Series
export async function getSeries(options: {
limit?: number
locale?: string
} = {}): Promise<PaginatedResponse<Series>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[isActive][equals]': 'true',
limit: String(options.limit || 20),
locale: options.locale || 'de',
depth: '2',
})
return fetchAPI<PaginatedResponse<Series>>(
`/api/series?${params}`,
{ tags: ['series'], defaultValue: emptyPaginatedResponse as PaginatedResponse<Series> }
)
}
export async function getSeriesBySlug(
slug: string,
locale = 'de'
): Promise<Series | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[isActive][equals]': 'true',
locale,
depth: '2',
})
const data = await fetchAPI<PaginatedResponse<Series>>(
`/api/series?${params}`,
{ tags: [`series-${slug}`] }
)
return data.docs[0] || null
}
// Newsletter Subscription
export async function subscribeNewsletter(
email: string,
firstName?: string,
source = 'website'
): Promise<{ success: boolean; message?: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/newsletter/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
firstName,
tenantId: Number(TENANT_ID),
source,
}),
})
return res.json()
}
// Contact Form Submission
export async function submitContactForm(data: {
name: string
email: string
phone?: string
subject: string
message: string
formId?: number
}): Promise<{ success: boolean; message?: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/form-submissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
form: data.formId || 1,
submissionData: [
{ field: 'name', value: data.name },
{ field: 'email', value: data.email },
{ field: 'phone', value: data.phone || '' },
{ field: 'subject', value: data.subject },
{ field: 'message', value: data.message },
],
}),
})
return res.json()
}

229
src/lib/structuredData.ts Normal file
View file

@ -0,0 +1,229 @@
import { absoluteUrl, getImageUrl } from './utils'
import type { Post, Series, Favorite, SiteSettings, Author } from './types'
/**
* Generate WebSite schema for the homepage
*/
export function generateWebSiteSchema(settings?: SiteSettings | null) {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: settings?.siteName || 'BlogWoman',
description: settings?.siteDescription || 'Lifestyle-Blog für moderne Frauen',
url: absoluteUrl('/'),
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: absoluteUrl('/blog?search={search_term_string}'),
},
'query-input': 'required name=search_term_string',
},
}
}
/**
* Generate Organization schema
*/
export function generateOrganizationSchema(settings?: SiteSettings | null) {
const logoUrl = settings?.logo?.url || '/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),
}
}
/**
* Generate BlogPosting schema for blog posts
*/
export function generateBlogPostingSchema(
post: Post,
settings?: SiteSettings | null
) {
const author = post.author as Author | undefined
const imageUrl = getImageUrl(post.featuredImage)
const logoUrl = settings?.logo?.url || '/logo.png'
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: imageUrl || undefined,
datePublished: post.publishedAt || post.createdAt,
dateModified: post.updatedAt,
author: author
? {
'@type': 'Person',
name: author.name,
url: absoluteUrl('/about'),
}
: undefined,
publisher: {
'@type': 'Organization',
name: settings?.siteName || 'BlogWoman',
logo: {
'@type': 'ImageObject',
url: absoluteUrl(logoUrl),
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': absoluteUrl(`/blog/${post.slug}`),
},
}
}
/**
* Generate BreadcrumbList schema
*/
export function generateBreadcrumbSchema(
items: Array<{ name: string; url: string }>
) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: absoluteUrl(item.url),
})),
}
}
/**
* Generate FAQPage schema for FAQ sections
*/
export function generateFAQSchema(
faqs: Array<{ question: string; answer: string }>
) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}
}
/**
* Generate VideoObject schema for YouTube embeds
*/
export function generateVideoSchema(
videoUrl: string,
title: string,
description?: string,
thumbnailUrl?: string,
uploadDate?: string
) {
// Extract YouTube video ID
const videoIdMatch = videoUrl.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
)
const videoId = videoIdMatch?.[1]
if (!videoId) return null
return {
'@context': 'https://schema.org',
'@type': 'VideoObject',
name: title,
description: description || title,
thumbnailUrl:
thumbnailUrl || `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
uploadDate: uploadDate || new Date().toISOString(),
contentUrl: `https://www.youtube.com/watch?v=${videoId}`,
embedUrl: `https://www.youtube.com/embed/${videoId}`,
}
}
/**
* Generate Product schema for affiliate products
*/
export function generateProductSchema(favorite: Favorite) {
const imageUrl = getImageUrl(favorite.image)
return {
'@context': 'https://schema.org',
'@type': 'Product',
name: favorite.title,
description: favorite.description,
image: imageUrl || undefined,
offers: favorite.price
? {
'@type': 'Offer',
price: favorite.price.replace(/[^0-9.,]/g, ''),
priceCurrency: 'EUR',
availability: 'https://schema.org/InStock',
url: favorite.affiliateUrl,
}
: undefined,
}
}
/**
* Generate ItemList schema for product collections
*/
export function generateProductListSchema(favorites: Favorite[]) {
return {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: favorites.map((favorite, index) => ({
'@type': 'ListItem',
position: index + 1,
item: generateProductSchema(favorite),
})),
}
}
/**
* Generate Person schema for author
*/
export function generatePersonSchema(author: Author) {
return {
'@context': 'https://schema.org',
'@type': 'Person',
name: author.name,
jobTitle: author.role,
image: author.avatar?.url,
url: absoluteUrl('/about'),
}
}
/**
* Generate CollectionPage schema for series
*/
export function generateSeriesSchema(series: Series) {
const imageUrl = getImageUrl(series.coverImage)
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: series.title,
description: series.meta?.description,
image: imageUrl || undefined,
url: absoluteUrl(`/serien/${series.slug}`),
...(series.youtubePlaylistId && {
mainEntity: {
'@type': 'VideoPlaylist',
name: series.title,
url: `https://www.youtube.com/playlist?list=${series.youtubePlaylistId}`,
},
}),
}
}

484
src/lib/types.ts Normal file
View file

@ -0,0 +1,484 @@
// Payload CMS Types for BlogWoman
export interface Media {
id: string
url: string
alt?: string
width?: number
height?: number
mimeType?: string
filename?: string
}
export interface RichText {
root: {
children: unknown[]
direction: string | null
format: string
indent: number
type: string
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
}

94
src/lib/utils.ts Normal file
View file

@ -0,0 +1,94 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(
date: string | Date,
options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'long',
year: 'numeric',
}
): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('de-DE', options)
}
export function formatShortDate(date: string | Date): string {
return formatDate(date, {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
export function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '')
}
export function truncate(text: string, length: number): string {
if (text.length <= length) return text
return text.slice(0, length).trim() + '...'
}
export function getImageUrl(
image: { url: string } | string | undefined | null
): string | undefined {
if (!image) return undefined
if (typeof image === 'string') return image
return image.url
}
export function absoluteUrl(path: string): string {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://blogwoman.de'
return `${siteUrl}${path.startsWith('/') ? path : `/${path}`}`
}
export function extractYouTubeId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/shorts\/([^&\n?#]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match?.[1]) return match[1]
}
return null
}
export function getYouTubeThumbnail(
videoIdOrUrl: string,
quality: 'default' | 'medium' | 'high' | 'max' = 'high'
): string {
const videoId = videoIdOrUrl.includes('http')
? extractYouTubeId(videoIdOrUrl)
: videoIdOrUrl
if (!videoId) return ''
const qualityMap = {
default: 'default',
medium: 'mqdefault',
high: 'hqdefault',
max: 'maxresdefault',
}
return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}.jpg`
}
export function getPrivacyYouTubeUrl(videoId: string): string {
return `https://www.youtube-nocookie.com/embed/${videoId}`
}

View file

@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "docs", "prompts"]
}