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"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.0.10",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
"react-dom": "19.2.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
next:
|
||||
specifier: 16.0.10
|
||||
version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
|
|
@ -17,6 +20,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: 19.2.1
|
||||
version: 19.2.1(react@19.2.1)
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
|
|
@ -799,6 +805,10 @@ packages:
|
|||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
|
@ -1785,6 +1795,9 @@ packages:
|
|||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
tailwind-merge@3.4.0:
|
||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||
|
||||
tailwindcss@4.1.18:
|
||||
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
||||
|
||||
|
|
@ -2649,6 +2662,8 @@ snapshots:
|
|||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
|
@ -3830,6 +3845,8 @@ snapshots:
|
|||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
|
||||
tailwindcss@4.1.18: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
|
|
|||
88
src/app/[slug]/page.tsx
Normal file
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";
|
||||
|
||||
/* ============================================
|
||||
BlogWoman Design System
|
||||
Philosophy: "Editorial Warmth"
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* Primary Colors (60/30/10 Rule) */
|
||||
--color-ivory: #F7F3EC;
|
||||
--color-sand: #C6A47E;
|
||||
--color-espresso: #2B2520;
|
||||
|
||||
/* Accent Colors (10%) */
|
||||
--color-brass: #B08D57;
|
||||
--color-brass-hover: #9E7E4D;
|
||||
--color-bordeaux: #6B1F2B;
|
||||
--color-rose: #D4A5A5;
|
||||
--color-gold: #C9A227;
|
||||
|
||||
/* Neutral Colors */
|
||||
--color-soft-white: #FBF8F3;
|
||||
--color-warm-gray: #DDD4C7;
|
||||
--color-warm-gray-dark: #B8ADA0;
|
||||
|
||||
/* Functional Colors */
|
||||
--color-success: #4A7C59;
|
||||
--color-warning: #D4A574;
|
||||
--color-error: #8B3A3A;
|
||||
--color-info: #6B8E9B;
|
||||
|
||||
/* Semantic Aliases */
|
||||
--color-background: var(--color-ivory);
|
||||
--color-surface: var(--color-soft-white);
|
||||
--color-text-primary: var(--color-espresso);
|
||||
--color-text-secondary: var(--color-warm-gray-dark);
|
||||
--color-border: var(--color-warm-gray);
|
||||
--color-primary: var(--color-brass);
|
||||
--color-primary-hover: var(--color-brass-hover);
|
||||
|
||||
/* Typography */
|
||||
--font-headline: 'Playfair Display', Georgia, serif;
|
||||
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
/* Font Sizes */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.875rem;
|
||||
--text-md: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.375rem;
|
||||
--text-2xl: 1.75rem;
|
||||
--text-3xl: 2.125rem;
|
||||
--text-4xl: 2.75rem;
|
||||
--text-5xl: 3.5rem;
|
||||
|
||||
/* Font Weights */
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* Line Heights */
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.65;
|
||||
--leading-loose: 1.7;
|
||||
|
||||
/* Spacing (8px base) */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
--space-24: 6rem;
|
||||
--space-32: 8rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-none: 0;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-2xl: 20px;
|
||||
--radius-3xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows (warm-tinted based on Espresso) */
|
||||
--shadow-sm: 0 1px 2px rgba(43, 37, 32, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(43, 37, 32, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(43, 37, 32, 0.1);
|
||||
--shadow-xl: 0 12px 40px rgba(43, 37, 32, 0.12);
|
||||
--shadow-2xl: 0 20px 60px rgba(43, 37, 32, 0.15);
|
||||
|
||||
/* Transitions */
|
||||
--ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
--ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
|
||||
/* Container */
|
||||
--container-sm: 640px;
|
||||
--container-md: 768px;
|
||||
--container-lg: 1024px;
|
||||
--container-xl: 1200px;
|
||||
--container-2xl: 1400px;
|
||||
--content-max-width: 1200px;
|
||||
--content-padding: var(--space-5);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--content-padding: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--content-padding: var(--space-10);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind CSS 4 Theme Extension */
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* 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 {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
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 { Header, Footer } from '@/components/layout'
|
||||
import { UmamiScript } from '@/components/analytics'
|
||||
import { getSiteSettings, getNavigation } from '@/lib/api'
|
||||
import { getSeoSettings, getSiteSettings, getNavigation } from '@/lib/api'
|
||||
import './globals.css'
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
|
|
@ -18,26 +18,50 @@ const inter = Inter({
|
|||
})
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getSiteSettings()
|
||||
const [settings, seoSettings] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
getSeoSettings(),
|
||||
])
|
||||
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
|
||||
const defaultDescription =
|
||||
seoSettings?.metaDefaults?.defaultDescription ||
|
||||
settings?.siteDescription ||
|
||||
'Lifestyle-Blog für moderne Frauen'
|
||||
const defaultImage = seoSettings?.metaDefaults?.defaultImage?.url
|
||||
const canIndex = seoSettings?.robots?.indexing !== false
|
||||
const verificationOther: Record<string, string> = {}
|
||||
|
||||
if (seoSettings?.verification?.bing) {
|
||||
verificationOther['msvalidate.01'] = seoSettings.verification.bing
|
||||
}
|
||||
if (seoSettings?.verification?.yandex) {
|
||||
verificationOther['yandex-verification'] = seoSettings.verification.yandex
|
||||
}
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: settings?.siteName || 'BlogWoman',
|
||||
template: `%s | ${settings?.siteName || 'BlogWoman'}`,
|
||||
template: `%s ${titleSuffix || `| ${settings?.siteName || 'BlogWoman'}`}`,
|
||||
},
|
||||
description: settings?.siteDescription || 'Lifestyle-Blog für moderne Frauen',
|
||||
description: defaultDescription,
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://blogwoman.de'),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'de_DE',
|
||||
siteName: settings?.siteName || 'BlogWoman',
|
||||
images: defaultImage ? [{ url: defaultImage }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
images: defaultImage ? [defaultImage] : undefined,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
index: canIndex,
|
||||
follow: canIndex,
|
||||
},
|
||||
verification: {
|
||||
google: seoSettings?.verification?.google,
|
||||
other: Object.keys(verificationOther).length ? verificationOther : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [page, seoSettings] = await Promise.all([
|
||||
getPage('home'),
|
||||
getSeoSettings(),
|
||||
])
|
||||
|
||||
if (!page) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const titleSuffix = seoSettings?.metaDefaults?.titleSuffix || ''
|
||||
const titleBase = page.meta?.title || page.title
|
||||
const title = titleSuffix ? `${titleBase} ${titleSuffix}` : titleBase
|
||||
const description =
|
||||
page.meta?.description || seoSettings?.metaDefaults?.defaultDescription
|
||||
const image =
|
||||
page.meta?.image?.url || seoSettings?.metaDefaults?.defaultImage?.url
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description: description || undefined,
|
||||
images: image ? [{ url: image }] : undefined,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description: description || undefined,
|
||||
images: image ? [image] : undefined,
|
||||
},
|
||||
robots: {
|
||||
index: !page.meta?.noIndex,
|
||||
follow: !page.meta?.noFollow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const [page, settings] = await Promise.all([
|
||||
getPage('home'),
|
||||
getSiteSettings(),
|
||||
])
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const webSiteSchema = generateWebSiteSchema(settings)
|
||||
const orgSchema = generateOrganizationSchema(settings)
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify([webSiteSchema, orgSchema]),
|
||||
}}
|
||||
/>
|
||||
<BlockRenderer blocks={page.layout} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "docs", "prompts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue