mirror of
https://github.com/complexcaresolutions/frontend.blogwoman.de.git
synced 2026-03-17 15:04:01 +00:00
Aktualisierung der Codebase
This commit is contained in:
parent
75f31b1cb8
commit
ba54d7a85d
42 changed files with 5304 additions and 87 deletions
199
CLAUDE.md
Normal file
199
CLAUDE.md
Normal 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)
|
||||||
|
|
@ -9,9 +9,11 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1"
|
"react-dom": "19.2.1",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
next:
|
next:
|
||||||
specifier: 16.0.10
|
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)
|
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:
|
react-dom:
|
||||||
specifier: 19.2.1
|
specifier: 19.2.1
|
||||||
version: 19.2.1(react@19.2.1)
|
version: 19.2.1(react@19.2.1)
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^3.4.0
|
||||||
|
version: 3.4.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
|
|
@ -799,6 +805,10 @@ packages:
|
||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
clsx@2.1.1:
|
||||||
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
@ -1785,6 +1795,9 @@ packages:
|
||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
tailwind-merge@3.4.0:
|
||||||
|
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||||
|
|
||||||
tailwindcss@4.1.18:
|
tailwindcss@4.1.18:
|
||||||
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
||||||
|
|
||||||
|
|
@ -2649,6 +2662,8 @@ snapshots:
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|
@ -3830,6 +3845,8 @@ snapshots:
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
tailwind-merge@3.4.0: {}
|
||||||
|
|
||||||
tailwindcss@4.1.18: {}
|
tailwindcss@4.1.18: {}
|
||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
|
||||||
88
src/app/[slug]/page.tsx
Normal file
88
src/app/[slug]/page.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
src/app/aktuelles/[slug]/page.tsx
Normal file
109
src/app/aktuelles/[slug]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
src/app/blog/[slug]/page.tsx
Normal file
109
src/app/blog/[slug]/page.tsx
Normal 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
175
src/app/blog/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
93
src/app/coming-soon/ComingSoonWrapper.tsx
Normal file
93
src/app/coming-soon/ComingSoonWrapper.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/app/coming-soon/page.tsx
Normal file
59
src/app/coming-soon/page.tsx
Normal 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
238
src/app/favoriten/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,449 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BlogWoman Design System
|
||||||
|
Philosophy: "Editorial Warmth"
|
||||||
|
============================================ */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
/* Primary Colors (60/30/10 Rule) */
|
||||||
--foreground: #171717;
|
--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 {
|
@media (min-width: 768px) {
|
||||||
--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) {
|
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--content-padding: var(--space-8);
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@media (min-width: 1024px) {
|
||||||
background: var(--background);
|
:root {
|
||||||
color: var(--foreground);
|
--content-padding: var(--space-10);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { Metadata } from 'next'
|
||||||
import { Playfair_Display, Inter } from 'next/font/google'
|
import { Playfair_Display, Inter } from 'next/font/google'
|
||||||
import { Header, Footer } from '@/components/layout'
|
import { Header, Footer } from '@/components/layout'
|
||||||
import { UmamiScript } from '@/components/analytics'
|
import { UmamiScript } from '@/components/analytics'
|
||||||
import { getSiteSettings, getNavigation } from '@/lib/api'
|
import { getSeoSettings, getSiteSettings, getNavigation } from '@/lib/api'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const playfair = Playfair_Display({
|
const playfair = Playfair_Display({
|
||||||
|
|
@ -18,26 +18,50 @@ const inter = Inter({
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
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 {
|
return {
|
||||||
title: {
|
title: {
|
||||||
default: settings?.siteName || 'BlogWoman',
|
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'),
|
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://blogwoman.de'),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
locale: 'de_DE',
|
locale: 'de_DE',
|
||||||
siteName: settings?.siteName || 'BlogWoman',
|
siteName: settings?.siteName || 'BlogWoman',
|
||||||
|
images: defaultImage ? [{ url: defaultImage }] : undefined,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
|
images: defaultImage ? [defaultImage] : undefined,
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: canIndex,
|
||||||
follow: true,
|
follow: canIndex,
|
||||||
|
},
|
||||||
|
verification: {
|
||||||
|
google: seoSettings?.verification?.google,
|
||||||
|
other: Object.keys(verificationOther).length ? verificationOther : undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
src/app/news/[slug]/page.tsx
Normal file
109
src/app/news/[slug]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
src/app/page.tsx
132
src/app/page.tsx
|
|
@ -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() {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
return (
|
const [page, seoSettings] = await Promise.all([
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
getPage('home'),
|
||||||
<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">
|
getSeoSettings(),
|
||||||
<Image
|
])
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
if (!page) {
|
||||||
alt="Next.js logo"
|
return {}
|
||||||
width={100}
|
}
|
||||||
height={20}
|
|
||||||
priority
|
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
|
||||||
/>
|
const titleBase = page.meta?.title || page.title
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
const description =
|
||||||
To get started, edit the page.tsx file.
|
page.meta?.description || seoSettings?.metaDefaults?.defaultDescription
|
||||||
</h1>
|
const image =
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
return {
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
title,
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
description,
|
||||||
>
|
openGraph: {
|
||||||
Templates
|
title,
|
||||||
</a>{" "}
|
description: description || undefined,
|
||||||
or the{" "}
|
images: image ? [{ url: image }] : undefined,
|
||||||
<a
|
type: 'website',
|
||||||
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"
|
twitter: {
|
||||||
>
|
card: 'summary_large_image',
|
||||||
Learning
|
title,
|
||||||
</a>{" "}
|
description: description || undefined,
|
||||||
center.
|
images: image ? [image] : undefined,
|
||||||
</p>
|
},
|
||||||
</div>
|
robots: {
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
index: !page.meta?.noIndex,
|
||||||
<a
|
follow: !page.meta?.noFollow,
|
||||||
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"
|
|
||||||
>
|
export default async function HomePage() {
|
||||||
<Image
|
const [page, settings] = await Promise.all([
|
||||||
className="dark:invert"
|
getPage('home'),
|
||||||
src="/vercel.svg"
|
getSiteSettings(),
|
||||||
alt="Vercel logomark"
|
])
|
||||||
width={16}
|
|
||||||
height={16}
|
if (!page) {
|
||||||
/>
|
notFound()
|
||||||
Deploy Now
|
}
|
||||||
</a>
|
|
||||||
<a
|
const webSiteSchema = generateWebSiteSchema(settings)
|
||||||
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]"
|
const orgSchema = generateOrganizationSchema(settings)
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
return (
|
||||||
rel="noopener noreferrer"
|
<>
|
||||||
>
|
<script
|
||||||
Documentation
|
type="application/ld+json"
|
||||||
</a>
|
dangerouslySetInnerHTML={{
|
||||||
</div>
|
__html: JSON.stringify([webSiteSchema, orgSchema]),
|
||||||
</main>
|
}}
|
||||||
</div>
|
/>
|
||||||
);
|
<BlockRenderer blocks={page.layout} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
src/app/presse/[slug]/page.tsx
Normal file
109
src/app/presse/[slug]/page.tsx
Normal 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
22
src/app/robots.ts
Normal 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`,
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/app/serien/[slug]/page.tsx
Normal file
133
src/app/serien/[slug]/page.tsx
Normal 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
125
src/app/serien/page.tsx
Normal 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
85
src/app/sitemap.ts
Normal 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
|
||||||
|
}
|
||||||
32
src/components/analytics/UmamiScript.tsx
Normal file
32
src/components/analytics/UmamiScript.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
src/components/analytics/index.ts
Normal file
2
src/components/analytics/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { UmamiScript } from './UmamiScript'
|
||||||
|
export { useAnalytics } from './useAnalytics'
|
||||||
90
src/components/analytics/useAnalytics.ts
Normal file
90
src/components/analytics/useAnalytics.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/components/blocks/CTABlock.tsx
Normal file
58
src/components/blocks/CTABlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/components/blocks/CardGridBlock.tsx
Normal file
97
src/components/blocks/CardGridBlock.tsx
Normal 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
|
||||||
|
}
|
||||||
180
src/components/blocks/ContactFormBlock.tsx
Normal file
180
src/components/blocks/ContactFormBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/components/blocks/ImageTextBlock.tsx
Normal file
51
src/components/blocks/ImageTextBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
278
src/components/blocks/SeriesBlock.tsx
Normal file
278
src/components/blocks/SeriesBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/components/blocks/SeriesDetailBlock.tsx
Normal file
130
src/components/blocks/SeriesDetailBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
src/components/blocks/StatsBlock.tsx
Normal file
146
src/components/blocks/StatsBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/components/blocks/TextBlock.tsx
Normal file
34
src/components/blocks/TextBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
src/components/blocks/VideoEmbedBlock.tsx
Normal file
111
src/components/blocks/VideoEmbedBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/components/blocks/index.tsx
Normal file
94
src/components/blocks/index.tsx
Normal 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'
|
||||||
291
src/components/layout/Footer.tsx
Normal file
291
src/components/layout/Footer.tsx
Normal 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>
|
||||||
|
© {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/components/layout/Header.tsx
Normal file
78
src/components/layout/Header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
src/components/layout/Navigation.tsx
Normal file
122
src/components/layout/Navigation.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
src/components/layout/index.ts
Normal file
4
src/components/layout/index.ts
Normal 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
105
src/components/ui/Input.tsx
Normal 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
382
src/lib/api.ts
Normal 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
229
src/lib/structuredData.ts
Normal 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
484
src/lib/types.ts
Normal 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
94
src/lib/utils.ts
Normal 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}`
|
||||||
|
}
|
||||||
|
|
@ -30,5 +30,5 @@
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "docs", "prompts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue