feat: add generic block renderer for CMS-driven pages

Replace hardcoded page content with a BlockRenderer that dispatches
CMS block types to dedicated React components. All 10 pages now
fetch their layout from Payload CMS and render blocks dynamically.

New block components: HeroBlock, TextBlock, TestimonialsBlock,
PricingBlock, CTABlock, CardGridBlock, PostsListBlock,
ContactFormBlock, FAQBlock, ImageTextBlock, RichText renderer.

Journal detail page now renders rich text content instead of stub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-28 13:06:50 +00:00
parent 95c8f1e1ed
commit b63d3bdb80
30 changed files with 877 additions and 917 deletions

View file

@ -1,53 +1,14 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "AGB | Sensual Moment Photography",
}
function extractTextFromRichText(richText: unknown): string[] {
if (!richText || typeof richText !== "object") return []
const root = (richText as { root?: { children?: unknown[] } }).root
if (!root?.children) return []
const paragraphs: string[] = []
for (const child of root.children) {
const node = child as { type?: string; children?: unknown[] }
if (node.children) {
const text = node.children
.map((n) => (n as { text?: string }).text || "")
.join("")
if (text) paragraphs.push(text)
}
}
return paragraphs
}
export default async function AGBPage() {
const page = await fetchPage("agb")
const blocks = (page?.layout as { blockType: string; content?: unknown }[]) || []
const textBlock = blocks.find((b) => b.blockType === "text-block")
const paragraphs = textBlock ? extractTextFromRichText(textBlock.content) : []
if (!page) notFound()
return (
<>
<section className="bg-dark-wine pt-40 pb-20 text-center">
<h1 className="font-playfair text-[clamp(2rem,4vw,3rem)] text-blush">
Allgemeine Geschaeftsbedingungen
</h1>
</section>
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[720px] mx-auto px-6">
<ScrollReveal>
<div className="font-cormorant text-[1.1rem] font-light text-espresso leading-[1.8] space-y-4">
{paragraphs.length > 0 ? (
paragraphs.map((p, i) => <p key={i}>{p}</p>)
) : (
<p>AGB werden in Kuerze ergaenzt.</p>
)}
</div>
</ScrollReveal>
</div>
</section>
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,51 +1,14 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "Datenschutz | Sensual Moment Photography",
}
function extractTextFromRichText(richText: unknown): string[] {
if (!richText || typeof richText !== "object") return []
const root = (richText as { root?: { children?: unknown[] } }).root
if (!root?.children) return []
const paragraphs: string[] = []
for (const child of root.children) {
const node = child as { type?: string; children?: unknown[] }
if (node.children) {
const text = node.children
.map((n) => (n as { text?: string }).text || "")
.join("")
if (text) paragraphs.push(text)
}
}
return paragraphs
}
export default async function DatenschutzPage() {
const page = await fetchPage("datenschutz")
const blocks = (page?.layout as { blockType: string; content?: unknown }[]) || []
const textBlock = blocks.find((b) => b.blockType === "text-block")
const paragraphs = textBlock ? extractTextFromRichText(textBlock.content) : []
if (!page) notFound()
return (
<>
<section className="bg-dark-wine pt-40 pb-20 text-center">
<h1 className="font-playfair text-[clamp(2rem,4vw,3rem)] text-blush">Datenschutz</h1>
</section>
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[720px] mx-auto px-6">
<ScrollReveal>
<div className="font-cormorant text-[1.1rem] font-light text-espresso leading-[1.8] space-y-4">
{paragraphs.length > 0 ? (
paragraphs.map((p, i) => <p key={i}>{p}</p>)
) : (
<p>Datenschutzerklaerung wird in Kuerze ergaenzt.</p>
)}
</div>
</ScrollReveal>
</div>
</section>
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,68 +1,15 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchFAQs } from "@/lib/api"
import { FAQAccordion } from "./FAQAccordion"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "FAQ | Sensual Moment Photography",
description: "Haeufig gestellte Fragen zu Boudoir-Shootings, Ablauf, Preisen und mehr.",
}
const categoryLabels: Record<string, string> = {
"vor-dem-shooting": "Vor dem Shooting",
"waehrend-des-shootings": "Waehrend des Shootings",
"nach-dem-shooting": "Nach dem Shooting",
"kosten-buchung": "Kosten & Buchung",
}
export default async function FAQPage() {
const allFaqs = await fetchFAQs()
const grouped = allFaqs.reduce((acc, faq) => {
const cat = faq.category || "allgemein"
if (!acc[cat]) acc[cat] = []
acc[cat].push(faq)
return acc
}, {} as Record<string, typeof allFaqs>)
const page = await fetchPage("faq")
if (!page) notFound()
return (
<>
{/* Hero */}
<section className="bg-dark-wine pt-40 pb-20 text-center">
<span className="section-label text-blush block mb-4">Haeufige Fragen</span>
<h1 className="font-playfair text-[clamp(2.5rem,5vw,4rem)] text-blush mb-4">
FAQ
</h1>
<div className="divider-line mx-auto" />
</section>
{/* FAQ groups */}
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[800px] mx-auto px-6 space-y-16">
{Object.entries(grouped).map(([category, faqs]) => (
<ScrollReveal key={category}>
<h2 className="font-playfair text-[clamp(1.5rem,3vw,2rem)] text-bordeaux mb-6">
{categoryLabels[category] || category}
</h2>
<div className="divider-line mb-8" />
<FAQAccordion faqs={faqs} />
</ScrollReveal>
))}
</div>
</section>
{/* CTA */}
<section className="bg-dark-wine py-20 text-center">
<ScrollReveal>
<h2 className="font-playfair text-[clamp(1.8rem,3vw,2.5rem)] text-blush mb-4">
Noch Fragen?
</h2>
<p className="font-cormorant text-[1.1rem] font-light text-blush/60 mb-8">
Schreib mir gerne ich beantworte jede Frage persoenlich.
</p>
<a href="/kontakt" className="btn-cta inline-block text-blush">
Kontakt aufnehmen
</a>
</ScrollReveal>
</section>
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,77 +1,15 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "Galerie | Sensual Moment Photography",
description: "Entdecke mein Portfolio — intime, kuenstlerische Boudoir-Fotografie voller Anmut und Selbstliebe.",
}
const categories = ["Alle", "Klassisch", "Elegant", "Artistisch", "Natuerlich", "Dramatisch", "Sinnlich"]
export default async function GaleriePage() {
const page = await fetchPage("galerie")
if (!page) notFound()
export default function GaleriePage() {
// Placeholder grid - will be replaced with CMS data
const images = Array.from({ length: 12 }, (_, i) => ({
category: categories[(i % 6) + 1],
}))
return (
<>
{/* Hero */}
<section className="bg-dark-wine pt-40 pb-20 text-center">
<span className="section-label text-blush block mb-4">Portfolio</span>
<h1 className="font-playfair text-[clamp(2.5rem,5vw,4rem)] text-blush mb-4">
Momente der <em>Selbstliebe</em>
</h1>
<div className="divider-line mx-auto" />
</section>
{/* Gallery grid */}
<section className="bg-dark-wine pb-[120px] max-[900px]:pb-20">
<div className="max-w-[1400px] mx-auto px-6">
{/* Filter tabs */}
<ScrollReveal>
<div className="flex flex-wrap justify-center gap-4 mb-12">
{categories.map((cat) => (
<button
key={cat}
className="font-josefin text-[0.6rem] font-light uppercase tracking-[0.2em] text-blush/50 hover:text-blush px-4 py-2 border border-blush/10 hover:border-blush/40 transition-all"
>
{cat}
</button>
))}
</div>
</ScrollReveal>
{/* Masonry-like grid */}
<div className="columns-2 min-[901px]:columns-3 min-[1200px]:columns-4 gap-2">
{images.map((img, i) => (
<ScrollReveal key={i} className="break-inside-avoid mb-2">
<div
className="group relative overflow-hidden bg-blush-soft cursor-pointer"
style={{ aspectRatio: i % 3 === 0 ? "3/4" : i % 3 === 1 ? "4/5" : "1/1" }}
>
<div className="absolute inset-0 bg-gradient-to-t from-dark-wine/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-400 flex items-end p-4">
<span className="font-josefin text-[0.55rem] font-light uppercase tracking-[0.2em] text-blush">
{img.category}
</span>
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-creme py-20 text-center">
<ScrollReveal>
<h2 className="font-playfair text-[clamp(1.8rem,3vw,2.5rem)] text-bordeaux mb-6">
Bereit fuer deine <em>eigene Geschichte</em>?
</h2>
<a href="/kontakt" className="btn-cta inline-block text-espresso border-espresso/20 hover:border-bordeaux hover:text-bordeaux">
Shooting anfragen
</a>
</ScrollReveal>
</section>
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,52 +1,14 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "Impressum | Sensual Moment Photography",
}
function extractTextFromRichText(richText: unknown): string[] {
if (!richText || typeof richText !== "object") return []
const root = (richText as { root?: { children?: unknown[] } }).root
if (!root?.children) return []
const paragraphs: string[] = []
for (const child of root.children) {
const node = child as { type?: string; children?: unknown[] }
if (node.children) {
const text = node.children
.map((n) => (n as { text?: string }).text || "")
.join("")
if (text) paragraphs.push(text)
}
}
return paragraphs
}
export default async function ImpressumPage() {
const page = await fetchPage("impressum")
const blocks = (page?.layout as { blockType: string; content?: unknown }[]) || []
const textBlock = blocks.find((b) => b.blockType === "text-block")
const paragraphs = textBlock ? extractTextFromRichText(textBlock.content) : []
if (!page) notFound()
return (
<>
<section className="bg-dark-wine pt-40 pb-20 text-center">
<h1 className="font-playfair text-[clamp(2rem,4vw,3rem)] text-blush">Impressum</h1>
</section>
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[720px] mx-auto px-6">
<ScrollReveal>
<div className="font-cormorant text-[1.1rem] font-light text-espresso leading-[1.8] space-y-4">
{paragraphs.length > 0 ? (
paragraphs.map((p, i) => <p key={i}>{p}</p>)
) : (
<p>Impressum wird in Kuerze ergaenzt.</p>
)}
</div>
</ScrollReveal>
</div>
</section>
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,4 +1,5 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { RichText } from "@/components/blocks/RichText"
import { fetchFromCMS } from "@/lib/api"
import { notFound } from "next/navigation"
@ -23,6 +24,11 @@ export default async function JournalDetailPage({ params }: { params: Promise<{
const post = docs[0]
if (!post) notFound()
const cmsUrl = process.env.NEXT_PUBLIC_CMS_URL || "https://pl.porwoll.tech"
const coverUrl = post.coverImage?.url
? (post.coverImage.url.startsWith("http") ? post.coverImage.url : cmsUrl + post.coverImage.url)
: null
return (
<>
{/* Hero */}
@ -37,14 +43,35 @@ export default async function JournalDetailPage({ params }: { params: Promise<{
</h1>
</section>
{/* Cover Image */}
{coverUrl && (
<section className="bg-creme pt-16">
<div className="max-w-[900px] mx-auto px-6">
<img
src={coverUrl}
alt={post.coverImage?.alt || post.title}
className="w-full aspect-[16/9] object-cover"
/>
</div>
</section>
)}
{/* Content */}
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[720px] mx-auto px-6">
<ScrollReveal>
<div className="font-cormorant text-[1.2rem] font-light text-espresso leading-[1.8]">
{post.excerpt && <p className="text-[1.3rem] text-espresso/80 mb-8">{post.excerpt}</p>}
<p className="text-espresso/50 italic">Vollstaendiger Inhalt folgt in Kuerze.</p>
</div>
{post.excerpt && (
<p className="font-cormorant text-[1.3rem] font-light text-espresso/80 leading-[1.8] mb-8">
{post.excerpt}
</p>
)}
{post.content ? (
<RichText content={post.content} />
) : (
<p className="font-cormorant text-[1.2rem] font-light text-espresso/50 italic">
Vollstaendiger Inhalt folgt in Kuerze.
</p>
)}
</ScrollReveal>
</div>
</section>

View file

@ -1,5 +1,6 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPosts } from "@/lib/api"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "Journal | Sensual Moment Photography",
@ -7,54 +8,8 @@ export const metadata = {
}
export default async function JournalPage() {
const posts = await fetchPosts(12)
const page = await fetchPage("journal")
if (!page) notFound()
return (
<>
{/* Hero */}
<section className="bg-dark-wine pt-40 pb-20 text-center">
<span className="section-label text-blush block mb-4">Journal</span>
<h1 className="font-playfair text-[clamp(2.5rem,5vw,4rem)] text-blush mb-4">
Gedanken & <em>Geschichten</em>
</h1>
<div className="divider-line mx-auto" />
</section>
{/* Posts grid */}
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
{posts.length > 0 ? (
<div className="grid grid-cols-1 min-[601px]:grid-cols-2 min-[901px]:grid-cols-3 gap-8">
{posts.map((post, i) => (
<ScrollReveal key={i}>
<a href={"/journal/" + post.slug} className="group block">
<div className="aspect-[4/3] bg-blush-soft mb-6" />
{post.publishedAt && (
<p className="font-josefin text-[0.55rem] font-light uppercase tracking-[0.15em] text-espresso/40 mb-2">
{new Date(post.publishedAt).toLocaleDateString("de-DE", { day: "numeric", month: "long", year: "numeric" })}
</p>
)}
<h3 className="font-playfair text-[1.2rem] text-espresso group-hover:text-bordeaux transition-colors duration-300 mb-3">
{post.title}
</h3>
{post.excerpt && (
<p className="font-cormorant text-[1rem] font-light text-espresso/70 leading-[1.7]">
{post.excerpt}
</p>
)}
</a>
</ScrollReveal>
))}
</div>
) : (
<div className="text-center py-20">
<p className="font-cormorant text-[1.2rem] font-light text-espresso/50">
Bald findest du hier inspirierende Beitraege rund um Boudoir-Fotografie und Selbstliebe.
</p>
</div>
)}
</div>
</section>
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,24 +1,15 @@
import { Contact } from "@/components/sections/Contact"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "Kontakt | Sensual Moment Photography",
description: "Kontaktiere mich fuer dein persoenliches Boudoir-Shooting — unverbindlich und vertraulich.",
}
export default function KontaktPage() {
return (
<>
{/* Hero */}
<section className="bg-dark-wine pt-40 pb-10 text-center">
<span className="section-label text-blush block mb-4">Kontakt</span>
<h1 className="font-playfair text-[clamp(2.5rem,5vw,4rem)] text-blush mb-4">
Schreib <em>mir</em>
</h1>
<div className="divider-line mx-auto" />
</section>
export default async function KontaktPage() {
const page = await fetchPage("kontakt")
if (!page) notFound()
{/* Contact section */}
<Contact />
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,24 +1,10 @@
import { Hero } from "@/components/sections/Hero"
import { AboutPreview } from "@/components/sections/AboutPreview"
import { GalleryPreview } from "@/components/sections/GalleryPreview"
import { Testimonials } from "@/components/sections/Testimonials"
import { Packages } from "@/components/sections/Packages"
import { BlogPreview } from "@/components/sections/BlogPreview"
import { Contact } from "@/components/sections/Contact"
import { fetchTestimonials } from "@/lib/api"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export default async function HomePage() {
const testimonials = await fetchTestimonials()
const page = await fetchPage("home")
if (!page) notFound()
return (
<>
<Hero />
<AboutPreview />
<GalleryPreview />
<Testimonials testimonials={testimonials} />
<Packages />
<BlogPreview />
<Contact />
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,7 +1,6 @@
import { Packages } from "@/components/sections/Packages"
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchFAQs } from "@/lib/api"
import { FAQAccordion } from "@/app/faq/FAQAccordion"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "Pakete & Preise | Sensual Moment Photography",
@ -9,55 +8,8 @@ export const metadata = {
}
export default async function PaketePage() {
const faqs = await fetchFAQs("kosten-buchung")
const page = await fetchPage("pakete")
if (!page) notFound()
return (
<>
{/* Hero */}
<section className="bg-dark-wine pt-40 pb-20 text-center">
<span className="section-label text-blush block mb-4">Investition in dich</span>
<h1 className="font-playfair text-[clamp(2.5rem,5vw,4rem)] text-blush mb-4">
Pakete & <em>Preise</em>
</h1>
<div className="divider-line mx-auto mb-8" />
<p className="font-cormorant text-[1.1rem] font-light text-blush/60 max-w-2xl mx-auto px-6">
Jedes Paket ist darauf ausgelegt, dir ein unvergessliches Erlebnis zu schenken.
Individuelle Anpassungen sind jederzeit moeglich.
</p>
</section>
{/* Packages */}
<Packages />
{/* FAQ section */}
{faqs.length > 0 && (
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[800px] mx-auto px-6">
<ScrollReveal>
<div className="text-center mb-12">
<span className="section-label text-espresso block mb-4">Haeufige Fragen</span>
<h2 className="font-playfair text-[clamp(1.8rem,3vw,2.5rem)] text-bordeaux mb-4">
Zu Kosten & <em>Buchung</em>
</h2>
<div className="divider-line mx-auto" />
</div>
</ScrollReveal>
<FAQAccordion faqs={faqs} />
</div>
</section>
)}
{/* CTA */}
<section className="bg-dark-wine py-20 text-center">
<ScrollReveal>
<h2 className="font-playfair text-[clamp(1.8rem,3vw,2.5rem)] text-blush mb-6">
Bereit fuer deinen <em>Moment</em>?
</h2>
<a href="/kontakt" className="btn-cta inline-block text-blush">
Jetzt Shooting buchen
</a>
</ScrollReveal>
</section>
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -1,93 +1,15 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPage } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks/BlockRenderer"
import { notFound } from "next/navigation"
export const metadata = {
title: "Ueber mich | Sensual Moment Photography",
description: "Lerne mich kennen — deine Boudoir-Fotografin mit Leidenschaft fuer Selbstliebe und Empowerment.",
}
export default function UeberMichPage() {
return (
<>
{/* Hero */}
<section className="bg-dark-wine pt-40 pb-20">
<div className="max-w-[1280px] mx-auto px-6 grid grid-cols-1 min-[901px]:grid-cols-2 gap-16 items-center">
<div className="aspect-[3/4] bg-blush-soft rounded-[0_120px_0_0]" />
<div>
<span className="section-label text-blush block mb-4">Ueber mich</span>
<h1 className="font-playfair text-[clamp(2.5rem,5vw,4rem)] text-blush mb-4">
Die Frau hinter <em>der Kamera</em>
</h1>
<div className="divider-line mb-8" />
<p className="font-cormorant text-[1.2rem] font-light text-blush/70 leading-[1.8]">
Ich bin leidenschaftliche Fotografin und glaube fest daran,
dass jede Frau eine Geschichte hat, die es wert ist, erzaehlt zu werden.
</p>
</div>
</div>
</section>
export default async function UeberMichPage() {
const page = await fetchPage("ueber-mich")
if (!page) notFound()
{/* Story */}
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[720px] mx-auto px-6">
<ScrollReveal>
<h2 className="font-playfair text-[clamp(2rem,4vw,3rem)] text-bordeaux mb-6">
Meine <em>Geschichte</em>
</h2>
<div className="divider-line mb-8" />
<div className="font-cormorant text-[1.2rem] font-light text-espresso leading-[1.8] space-y-6">
<p>
Mein Weg zur Boudoir-Fotografie begann mit einer einfachen Erkenntnis:
Zu viele Frauen sehen sich selbst nicht so, wie andere sie sehen
wunderschoen, stark und einzigartig.
</p>
<p>
Seit ueber zehn Jahren begleite ich Frauen auf ihrer Reise der Selbstentdeckung.
Jedes Shooting ist fuer mich eine Ehre und ein Privileg. Mein Studio ist ein
sicherer Raum, in dem du dich fallen lassen kannst.
</p>
<p>
Ich arbeite mit natuerlichem Licht und einer ruhigen, achtsamen Atmosphaere.
Keine gestellten Posen, keine Perfektion nur du, in deiner authentischsten Form.
</p>
</div>
</ScrollReveal>
</div>
</section>
{/* Values */}
<section className="bg-creme pb-[120px] max-[900px]:pb-20">
<div className="max-w-[1100px] mx-auto px-6">
<ScrollReveal>
<h2 className="font-playfair text-[clamp(1.8rem,3vw,2.5rem)] text-bordeaux text-center mb-12">
Meine <em>Werte</em>
</h2>
<div className="grid grid-cols-1 min-[901px]:grid-cols-3 gap-8">
{[
{ title: "Vertrauen", text: "Dein Wohlbefinden steht immer an erster Stelle. Jedes Bild entsteht nur mit deinem ausdruecklichen Einverstaendnis." },
{ title: "Authentizitaet", text: "Keine uebermaessige Retusche, keine kuenstlichen Posen. Ich zeige dich so, wie du wirklich bist — und das ist wunderschoen." },
{ title: "Empowerment", text: "Ein Boudoir-Shooting ist ein Akt der Selbstliebe. Es geht nicht darum, wie andere dich sehen, sondern wie du dich fuehlst." },
].map((v, i) => (
<div key={i} className="text-center p-8">
<h3 className="font-playfair text-[1.4rem] text-bordeaux mb-4">{v.title}</h3>
<p className="font-cormorant text-[1.05rem] font-light text-espresso leading-[1.75]">{v.text}</p>
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
{/* CTA */}
<section className="bg-dark-wine py-20 text-center">
<ScrollReveal>
<h2 className="font-playfair text-[clamp(1.8rem,3vw,2.5rem)] text-blush mb-6">
Lass uns <em>kennenlernen</em>
</h2>
<a href="/kontakt" className="btn-cta inline-block text-blush">
Kontakt aufnehmen
</a>
</ScrollReveal>
</section>
</>
)
return <BlockRenderer blocks={page.layout as any[]} />
}

View file

@ -0,0 +1,55 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HeroBlock } from "./HeroBlock"
import { TextBlock } from "./TextBlock"
import { TestimonialsBlock } from "./TestimonialsBlock"
import { PricingBlock } from "./PricingBlock"
import { CTABlock } from "./CTABlock"
import { CardGridBlock } from "./CardGridBlock"
import { PostsListBlock } from "./PostsListBlock"
import { ContactFormBlock } from "./ContactFormBlock"
import { FAQBlock } from "./FAQBlock"
import { ImageTextBlock } from "./ImageTextBlock"
interface BlockRendererProps {
blocks: any[]
}
export function BlockRenderer({ blocks }: BlockRendererProps) {
if (!blocks?.length) return null
return (
<>
{blocks.map((block: any, index: number) => {
const key = block.id || `block-${index}`
switch (block.blockType) {
case "hero-block":
return <HeroBlock key={key} block={block} isFirst={index === 0} />
case "text-block":
return <TextBlock key={key} block={block} />
case "testimonials-block":
return <TestimonialsBlock key={key} block={block} />
case "pricing":
return <PricingBlock key={key} block={block} />
case "cta-block":
return <CTABlock key={key} block={block} />
case "card-grid-block":
return <CardGridBlock key={key} block={block} />
case "posts-list-block":
return <PostsListBlock key={key} block={block} />
case "contact-form-block":
return <ContactFormBlock key={key} block={block} />
case "faq-block":
return <FAQBlock key={key} block={block} />
case "image-text-block":
return <ImageTextBlock key={key} block={block} />
default:
if (process.env.NODE_ENV === "development") {
console.warn("Unknown block type:", block.blockType)
}
return null
}
})}
</>
)
}

View file

@ -0,0 +1,53 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ScrollReveal } from "@/components/ScrollReveal"
interface CTABlockProps {
block: {
headline?: string
description?: string
buttons?: { text?: string; link?: string; style?: string }[]
backgroundColor?: string
}
}
export function CTABlock({ block }: CTABlockProps) {
const bgMap: Record<string, string> = {
dark: "bg-dark-wine text-blush",
light: "bg-creme text-espresso",
accent: "bg-bordeaux text-blush",
}
const bg = bgMap[block.backgroundColor || "dark"] || bgMap.dark
const isLight = block.backgroundColor === "light"
return (
<section className={`${bg} py-[100px] max-[900px]:py-16`}>
<div className="max-w-[800px] mx-auto px-6 text-center">
<ScrollReveal>
{block.headline && (
<h2 className={`font-playfair text-[clamp(1.8rem,3.5vw,2.8rem)] mb-4 ${isLight ? "text-bordeaux" : ""}`}>
{block.headline}
</h2>
)}
{block.description && (
<p className={`font-cormorant text-[1.1rem] font-light leading-[1.8] mb-8 ${isLight ? "text-espresso/70" : "opacity-70"}`}>
{block.description}
</p>
)}
{block.buttons?.length ? (
<div className="flex flex-wrap gap-4 justify-center">
{block.buttons.map((btn, i) => (
<a
key={i}
href={btn.link || "#"}
className={btn.style === "filled" ? "btn-submit inline-block" : `btn-cta inline-block ${isLight ? "text-espresso hover:text-espresso" : "text-blush hover:text-blush"}`}
>
{btn.text}
</a>
))}
</div>
) : null}
</ScrollReveal>
</div>
</section>
)
}

View file

@ -0,0 +1,55 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ScrollReveal } from "@/components/ScrollReveal"
interface CardGridBlockProps {
block: {
headline?: string
cards?: any[]
columns?: string
}
}
export function CardGridBlock({ block }: CardGridBlockProps) {
const cards = block.cards || []
if (!cards.length) return null
const cols = block.columns === "2" ? "min-[701px]:grid-cols-2" : block.columns === "4" ? "min-[701px]:grid-cols-2 min-[1001px]:grid-cols-4" : "min-[701px]:grid-cols-2 min-[1001px]:grid-cols-3"
return (
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
{block.headline && (
<ScrollReveal>
<div className="text-center mb-16">
<h2 className="font-playfair text-[clamp(1.8rem,3.5vw,2.8rem)] text-bordeaux">
{block.headline}
</h2>
<div className="divider-line mx-auto mt-4" />
</div>
</ScrollReveal>
)}
<div className={`grid grid-cols-1 ${cols} gap-8`}>
{cards.map((card: any, i: number) => (
<ScrollReveal key={i}>
<div className="bg-white p-8 border border-blush/20 text-center hover:shadow-lg hover:-translate-y-px transition-all duration-300">
<h3 className="font-playfair text-[1.3rem] text-bordeaux mb-3">
{card.title}
</h3>
{card.description && (
<p className="font-cormorant text-[1.05rem] font-light text-espresso/70 leading-[1.7]">
{card.description}
</p>
)}
{card.link && card.linkText && (
<a href={card.link} className="btn-cta inline-block text-espresso hover:text-espresso mt-6 text-[0.6rem]">
{card.linkText}
</a>
)}
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
)
}

View file

@ -3,7 +3,17 @@
import { useState } from "react"
import { ScrollReveal } from "@/components/ScrollReveal"
export function Contact() {
interface ContactFormBlockProps {
block: {
form?: number | { id: number }
headline?: string
description?: string
successMessage?: string
showContactInfo?: boolean
}
}
export function ContactFormBlock({ block }: ContactFormBlockProps) {
const [formData, setFormData] = useState({
name: "",
email: "",
@ -14,6 +24,8 @@ export function Contact() {
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const formId = typeof block.form === "object" ? block.form?.id : block.form || 4
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
@ -24,7 +36,7 @@ export function Contact() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
form: 4, // sensualmoment contact form ID
form: formId,
submissionData: [
{ field: "name", value: formData.name },
{ field: "email", value: formData.email },
@ -51,14 +63,21 @@ export function Contact() {
<div>
<span className="section-label text-blush block mb-4">Kontakt</span>
<h2 className="font-playfair text-[clamp(2rem,4vw,3.2rem)] text-blush mb-4">
Bereit fuer deinen <em>Moment</em>?
{block.headline ? (
block.headline.includes("Moment") ? (
<>{block.headline.split("Moment")[0]}<em>Moment</em>{block.headline.split("Moment")[1]}</>
) : block.headline
) : (
<>Bereit fuer deinen <em>Moment</em>?</>
)}
</h2>
<div className="divider-line mb-8" />
{block.description && (
<p className="font-cormorant text-[1.1rem] font-light text-blush/70 leading-[1.8] mb-8">
Ich freue mich darauf, dich kennenzulernen und gemeinsam deinen
ganz persoenlichen Moment zu gestalten. Schreib mir oder ruf mich an
unverbindlich und vertraulich.
{block.description}
</p>
)}
{block.showContactInfo !== false && (
<div className="space-y-4 font-cormorant text-[1rem] font-light text-blush/60">
<p>
<span className="font-josefin text-[0.55rem] uppercase tracking-[0.2em] text-blush/40 block mb-1">E-Mail</span>
@ -73,6 +92,7 @@ export function Contact() {
Musterstrasse 42, 12345 Musterstadt
</p>
</div>
)}
</div>
</ScrollReveal>
@ -82,7 +102,7 @@ export function Contact() {
<div className="flex items-center justify-center h-full">
<div className="text-center">
<h3 className="font-playfair text-2xl text-blush mb-4">
Vielen Dank!
{block.successMessage || "Vielen Dank!"}
</h3>
<p className="font-cormorant text-[1.05rem] font-light text-blush/70">
Deine Nachricht ist angekommen. Ich melde mich innerhalb von 24 Stunden bei dir.

View file

@ -10,6 +10,7 @@ interface FAQ {
interface FAQAccordionProps {
faqs: FAQ[]
expandFirst?: boolean
}
function extractText(richText: unknown): string {
@ -31,8 +32,8 @@ function extractText(richText: unknown): string {
return walk(root.children)
}
export function FAQAccordion({ faqs }: FAQAccordionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null)
export function FAQAccordion({ faqs, expandFirst = false }: FAQAccordionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(expandFirst ? 0 : null)
return (
<div className="space-y-3">

View file

@ -0,0 +1,52 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fetchFAQs } from "@/lib/api"
import { ScrollReveal } from "@/components/ScrollReveal"
import { FAQAccordion } from "./FAQAccordion"
interface FAQBlockProps {
block: {
title?: string
sourceMode?: string
displayMode?: string
category?: string
limit?: number
layout?: string
expandFirst?: boolean
backgroundColor?: string
}
}
export async function FAQBlock({ block }: FAQBlockProps) {
const faqs = await fetchFAQs(block.category || undefined)
const items = block.limit ? faqs.slice(0, block.limit) : faqs
if (!items.length) return null
const bgMap: Record<string, string> = {
light: "bg-creme",
dark: "bg-dark-wine",
white: "bg-white",
}
const bg = bgMap[block.backgroundColor || "light"] || bgMap.light
const isDark = block.backgroundColor === "dark"
return (
<section className={`${bg} py-[100px] max-[900px]:py-16`}>
<div className="max-w-[800px] mx-auto px-6">
{block.title && (
<ScrollReveal>
<div className="text-center mb-12">
<h2 className={`font-playfair text-[clamp(1.6rem,3vw,2.4rem)] ${isDark ? "text-blush" : "text-bordeaux"}`}>
{block.title}
</h2>
<div className="divider-line mx-auto mt-4" />
</div>
</ScrollReveal>
)}
<ScrollReveal>
<FAQAccordion faqs={items} expandFirst={block.expandFirst} />
</ScrollReveal>
</div>
</section>
)
}

View file

@ -0,0 +1,97 @@
"use client"
import { useEffect, useState } from "react"
interface HeroBlockProps {
block: {
headline?: string
subline?: string
alignment?: string
overlay?: boolean
cta?: { text?: string; link?: string; style?: string }
backgroundImage?: { url?: string; alt?: string } | number
}
isFirst?: boolean
}
export function HeroBlock({ block, isFirst = false }: HeroBlockProps) {
const [visible, setVisible] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setVisible(true), 100)
return () => clearTimeout(timer)
}, [])
const bgImage = typeof block.backgroundImage === "object" && block.backgroundImage?.url
? block.backgroundImage.url
: null
const cmsUrl = process.env.NEXT_PUBLIC_CMS_URL || "https://pl.porwoll.tech"
const bgUrl = bgImage ? (bgImage.startsWith("http") ? bgImage : cmsUrl + bgImage) : null
const alignment = block.alignment || "center"
const alignClass = alignment === "left" ? "text-left items-start" : alignment === "right" ? "text-right items-end" : "text-center items-center"
// Split headline for Sensual / Moment style rendering
const headlineParts = block.headline?.split(/\s+/) || []
const isHomeHero = isFirst && headlineParts.length <= 3
return (
<section
className={`relative flex justify-center overflow-hidden bg-dark-wine ${isFirst ? "min-h-screen" : "pt-40 pb-20"} ${alignClass}`}
style={bgUrl ? { backgroundImage: `url(${bgUrl})`, backgroundSize: "cover", backgroundPosition: "center" } : undefined}
>
{/* Gradient overlays */}
{block.overlay !== false && (
<>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_20%_50%,rgba(139,58,74,0.15),transparent_70%)]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_80%_50%,rgba(212,169,160,0.08),transparent_70%)]" />
{bgUrl && <div className="absolute inset-0 bg-dark-wine/60" />}
</>
)}
<div
className={`relative z-10 px-6 flex flex-col justify-center ${isFirst ? "min-h-screen" : ""} ${alignClass} transition-all duration-[1.2s] ease-out ${visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"}`}
>
{/* Decorative line */}
<div className={`w-[60px] h-px mb-10 bg-gradient-to-r from-transparent via-blush to-transparent ${alignment === "center" ? "mx-auto" : ""}`} />
{isHomeHero && headlineParts.length >= 2 ? (
<h1 className="font-playfair text-blush">
<span className="block text-[clamp(3.5rem,8vw,7rem)] font-normal leading-[1.05]">
{headlineParts[0]}
</span>
<span className="block text-[clamp(2.5rem,6vw,5rem)] italic font-normal -mt-2">
{headlineParts.slice(1).join(" ")}
</span>
</h1>
) : (
<h1 className="font-playfair text-[clamp(2.2rem,5vw,4rem)] text-blush font-normal leading-[1.1]">
{block.headline}
</h1>
)}
{block.subline && (
<p className="font-josefin text-[0.6rem] font-light uppercase tracking-[0.4em] text-blush/50 mt-6">
{block.subline}
</p>
)}
{block.cta?.text && (
<div className="mt-12">
<a href={block.cta.link || "#"} className="btn-cta inline-block text-blush hover:text-blush">
{block.cta.text}
</a>
</div>
)}
</div>
{/* Scroll indicator (only on first/full-screen hero) */}
{isFirst && (
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 animate-pulse">
<span className="font-josefin text-[0.5rem] font-light uppercase tracking-[0.2em] text-blush/30">Scroll</span>
<div className="w-px h-8 bg-gradient-to-b from-blush/40 to-transparent" />
</div>
)}
</section>
)
}

View file

@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { RichText } from "./RichText"
import { ScrollReveal } from "@/components/ScrollReveal"
interface ImageTextBlockProps {
block: {
image?: { url?: string; alt?: string } | number
imagePosition?: string
headline?: string
content?: unknown
cta?: { text?: string; link?: string }
}
}
export function ImageTextBlock({ block }: ImageTextBlockProps) {
const cmsUrl = process.env.NEXT_PUBLIC_CMS_URL || "https://pl.porwoll.tech"
const imgObj = typeof block.image === "object" ? block.image : null
const imageUrl = imgObj?.url ? (imgObj.url.startsWith("http") ? imgObj.url : cmsUrl + imgObj.url) : null
const isRight = block.imagePosition === "right"
return (
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
<div className={`grid grid-cols-1 min-[901px]:grid-cols-2 gap-16 items-center ${isRight ? "" : ""}`}>
{/* Image */}
<ScrollReveal className={isRight ? "min-[901px]:order-2" : ""}>
{imageUrl ? (
<div className="aspect-[3/4] overflow-hidden rounded-[0_120px_0_0]">
<img
src={imageUrl}
alt={imgObj?.alt || block.headline || ""}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="aspect-[3/4] bg-gradient-to-br from-blush/20 to-bordeaux/10 rounded-[0_120px_0_0]" />
)}
</ScrollReveal>
{/* Text */}
<ScrollReveal className={isRight ? "min-[901px]:order-1" : ""}>
<div>
{block.headline && (
<>
<span className="section-label text-bordeaux block mb-3">
{block.imagePosition === "left" ? "Philosophie" : "Ueber mich"}
</span>
<h2 className="font-playfair text-[clamp(1.6rem,3vw,2.4rem)] text-bordeaux mb-4">
{block.headline}
</h2>
<div className="divider-line mb-8" />
</>
)}
<RichText
content={block.content}
className="[&_p]:font-cormorant [&_p]:text-[1.05rem] [&_p]:font-light [&_p]:text-espresso/70 [&_p]:leading-[1.8] [&_p]:mb-4"
/>
{block.cta?.text && (
<a href={block.cta.link || "#"} className="btn-cta inline-block text-espresso hover:text-espresso mt-6">
{block.cta.text}
</a>
)}
</div>
</ScrollReveal>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fetchPosts } from "@/lib/api"
import { ScrollReveal } from "@/components/ScrollReveal"
interface PostsListBlockProps {
block: {
title?: string
layout?: string
columns?: string
limit?: number
showExcerpt?: boolean
showDate?: boolean
showAuthor?: boolean
}
}
export async function PostsListBlock({ block }: PostsListBlockProps) {
const limit = block.limit || 3
const posts = await fetchPosts(limit)
if (!posts.length) return null
const cols = block.columns === "2" ? "min-[601px]:grid-cols-2" : "min-[601px]:grid-cols-2 min-[901px]:grid-cols-3"
const cmsUrl = process.env.NEXT_PUBLIC_CMS_URL || "https://pl.porwoll.tech"
return (
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
{block.title && (
<ScrollReveal>
<div className="text-center mb-16">
<span className="section-label text-bordeaux block mb-3">Journal</span>
<h2 className="font-playfair text-[clamp(1.8rem,3.5vw,2.8rem)] text-bordeaux">
{block.title}
</h2>
<div className="divider-line mx-auto mt-4" />
</div>
</ScrollReveal>
)}
<div className={`grid grid-cols-1 ${cols} gap-8`}>
{posts.map((post: any) => {
const imageUrl = typeof post.coverImage === "object" && post.coverImage?.url
? (post.coverImage.url.startsWith("http") ? post.coverImage.url : cmsUrl + post.coverImage.url)
: null
return (
<ScrollReveal key={post.slug}>
<a href={`/journal/${post.slug}`} className="group block">
<div className="aspect-[4/3] bg-blush/10 mb-4 overflow-hidden">
{imageUrl ? (
<img
src={imageUrl}
alt={post.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blush/20 to-bordeaux/10" />
)}
</div>
{block.showDate !== false && post.publishedAt && (
<p className="font-josefin text-[0.5rem] uppercase tracking-[0.2em] text-espresso/40 mb-2">
{new Date(post.publishedAt).toLocaleDateString("de-DE", { day: "numeric", month: "long", year: "numeric" })}
</p>
)}
<h3 className="font-playfair text-[1.2rem] text-bordeaux group-hover:text-blush transition-colors mb-2">
{post.title}
</h3>
{block.showExcerpt !== false && post.excerpt && (
<p className="font-cormorant text-[1rem] font-light text-espresso/60 leading-[1.6] line-clamp-2">
{post.excerpt}
</p>
)}
</a>
</ScrollReveal>
)
})}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ScrollReveal } from "@/components/ScrollReveal"
interface PricingBlockProps {
block: {
title?: string
description?: string
pricingType?: string
currency?: string
showCurrencyBefore?: boolean
plans?: any[]
backgroundColor?: string
cardStyle?: string
}
}
export function PricingBlock({ block }: PricingBlockProps) {
const plans = block.plans || []
if (!plans.length) return null
const currency = block.currency || "\u20AC"
const before = block.showCurrencyBefore !== false
return (
<section className="bg-navy py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
{block.title && (
<ScrollReveal>
<div className="text-center mb-16">
<span className="section-label text-blush block mb-3">Investition</span>
<h2 className="font-playfair text-[clamp(1.8rem,3.5vw,2.8rem)] text-blush">
{block.title}
</h2>
{block.description && (
<p className="font-cormorant text-[1.1rem] font-light text-blush/60 mt-4 max-w-[600px] mx-auto">
{block.description}
</p>
)}
<div className="divider-line mx-auto mt-4" />
</div>
</ScrollReveal>
)}
<div className={`grid grid-cols-1 ${plans.length === 2 ? "min-[701px]:grid-cols-2 max-w-[800px]" : "min-[701px]:grid-cols-3"} gap-8 mx-auto`}>
{plans.map((plan: any, i: number) => (
<ScrollReveal key={i}>
<div className={`border p-8 text-center flex flex-col h-full relative ${plan.isPopular ? "border-blush" : "border-blush/20"}`}>
{plan.isPopular && plan.popularLabel && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="font-josefin text-[0.5rem] uppercase tracking-[0.2em] bg-blush text-dark-wine px-4 py-1">
{plan.popularLabel}
</span>
</div>
)}
<h3 className="font-playfair text-[1.4rem] text-blush mb-2">{plan.name}</h3>
<p className="font-playfair text-[2.5rem] text-blush mb-6">
{before ? currency : ""}{plan.price}{before ? "" : currency}
</p>
<ul className="space-y-3 mb-8 flex-1">
{plan.features?.map((f: any, j: number) => (
<li key={j} className="font-cormorant text-[1rem] font-light text-blush/70 flex items-start gap-2">
<span className="text-blush/40 mt-0.5 shrink-0">&#10003;</span>
<span>{typeof f === "string" ? f : f.feature || f.text}</span>
</li>
))}
</ul>
{plan.ctaText && (
<a
href={plan.ctaLink || "/kontakt"}
className={plan.isPopular ? "btn-submit inline-block" : "btn-cta inline-block text-blush hover:text-blush"}
>
{plan.ctaText}
</a>
)}
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type ReactNode } from "react"
interface RichTextProps {
content: unknown
className?: string
}
function renderText(node: any): ReactNode {
if (!node.text && node.text !== "") return null
let el: ReactNode = node.text
const format = node.format || 0
if (format & 1) el = <strong key="b">{el}</strong>
if (format & 2) el = <em key="i">{el}</em>
if (format & 4) el = <s key="s">{el}</s>
if (format & 8) el = <u key="u">{el}</u>
if (format & 16) el = <code key="c" className="bg-blush/10 px-1.5 py-0.5 text-[0.9em]">{el}</code>
return el
}
function renderNode(node: any, index: number): ReactNode {
if (!node) return null
if (node.type === "text") {
return <span key={index}>{renderText(node)}</span>
}
if (node.type === "linebreak") {
return <br key={index} />
}
if (node.type === "link") {
const url = node.fields?.url || node.url || "#"
const newTab = node.fields?.newTab || node.newTab
return (
<a
key={index}
href={url}
{...(newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="text-bordeaux underline underline-offset-2 hover:text-blush transition-colors"
>
{node.children?.map((child: any, i: number) => renderNode(child, i))}
</a>
)
}
const children = node.children?.map((child: any, i: number) => renderNode(child, i))
if (node.type === "heading") {
const tag = node.tag || "h2"
const sizes: Record<string, string> = {
h1: "text-[clamp(2.2rem,4vw,3.5rem)]",
h2: "text-[clamp(1.8rem,3.5vw,2.8rem)]",
h3: "text-[clamp(1.4rem,2.5vw,2rem)]",
h4: "text-[clamp(1.2rem,2vw,1.6rem)]",
h5: "text-[clamp(1rem,1.5vw,1.3rem)]",
h6: "text-[1rem]",
}
const className = `font-playfair text-bordeaux mb-4 ${sizes[tag] || sizes.h2}`
switch (tag) {
case "h1": return <h1 key={index} className={className}>{children}</h1>
case "h2": return <h2 key={index} className={className}>{children}</h2>
case "h3": return <h3 key={index} className={className}>{children}</h3>
case "h4": return <h4 key={index} className={className}>{children}</h4>
case "h5": return <h5 key={index} className={className}>{children}</h5>
case "h6": return <h6 key={index} className={className}>{children}</h6>
default: return <h2 key={index} className={className}>{children}</h2>
}
}
if (node.type === "paragraph") {
return (
<p key={index} className="font-cormorant text-[1.1rem] font-light text-espresso/80 leading-[1.8] mb-4">
{children}
</p>
)
}
if (node.type === "list") {
const Tag = node.listType === "number" ? "ol" : "ul"
return (
<Tag key={index} className={`font-cormorant text-[1.05rem] font-light text-espresso/80 leading-[1.8] mb-4 pl-6 ${node.listType === "number" ? "list-decimal" : "list-disc"}`}>
{children}
</Tag>
)
}
if (node.type === "listitem") {
return <li key={index} className="mb-1">{children}</li>
}
// Fallback: render children if any
if (children) return <>{children}</>
return null
}
export function RichText({ content, className }: RichTextProps) {
if (!content || typeof content !== "object") return null
const root = (content as any).root
if (!root?.children?.length) return null
return (
<div className={className}>
{root.children.map((node: any, i: number) => renderNode(node, i))}
</div>
)
}

View file

@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fetchTestimonials } from "@/lib/api"
import { ScrollReveal } from "@/components/ScrollReveal"
interface TestimonialsBlockProps {
block: {
title?: string
layout?: string
columns?: string
displayMode?: string
limit?: number
backgroundColor?: string
}
}
export async function TestimonialsBlock({ block }: TestimonialsBlockProps) {
const testimonials = await fetchTestimonials()
const items = block.limit ? testimonials.slice(0, block.limit) : testimonials
if (!items.length) return null
const cols = block.columns === "2" ? "min-[901px]:grid-cols-2" : "min-[901px]:grid-cols-3"
return (
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
{block.title && (
<ScrollReveal>
<div className="text-center mb-16">
<span className="section-label text-bordeaux block mb-3">Kundenstimmen</span>
<h2 className="font-playfair text-[clamp(1.8rem,3.5vw,2.8rem)] text-bordeaux">
{block.title}
</h2>
<div className="divider-line mx-auto mt-4" />
</div>
</ScrollReveal>
)}
<div className={`grid grid-cols-1 ${cols} gap-8`}>
{items.map((t: any, i: number) => (
<ScrollReveal key={i}>
<div className="bg-white p-8 border border-blush/20 hover:shadow-lg hover:-translate-y-px transition-all duration-300">
<div className="font-playfair text-4xl text-blush/30 mb-4">&ldquo;</div>
<p className="font-cormorant text-[1.05rem] font-light text-espresso/80 leading-[1.8] mb-6 italic">
{t.quote}
</p>
<div>
<p className="font-josefin text-[0.6rem] uppercase tracking-[0.15em] text-espresso">
{t.author}
</p>
{t.role && (
<p className="font-cormorant text-[0.9rem] font-light text-espresso/50 mt-1">
{t.role}
</p>
)}
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { RichText } from "./RichText"
interface TextBlockProps {
block: {
content?: unknown
width?: string
}
}
const widthMap: Record<string, string> = {
narrow: "max-w-[620px]",
medium: "max-w-[720px]",
full: "max-w-[1280px]",
}
export function TextBlock({ block }: TextBlockProps) {
const width = widthMap[block.width || "medium"] || widthMap.medium
return (
<section className="bg-creme py-[100px] max-[900px]:py-16">
<div className={`${width} mx-auto px-6`}>
<RichText content={block.content} />
</div>
</section>
)
}

View file

@ -1,47 +0,0 @@
import { ScrollReveal } from "@/components/ScrollReveal"
export function AboutPreview() {
return (
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
<ScrollReveal>
<div className="grid grid-cols-1 min-[901px]:grid-cols-2 gap-16 items-center">
{/* Image placeholder */}
<div className="aspect-[3/4] bg-blush-soft rounded-[0_120px_0_0]" />
{/* Text */}
<div>
<span className="section-label text-espresso block mb-4">Über mich</span>
<h2 className="font-playfair text-[clamp(2rem,4vw,3.2rem)] text-bordeaux mb-4">
Jede Frau verdient es,{" "}
<em>sich selbst zu feiern</em>
</h2>
<div className="divider-line mb-8" />
<div className="font-cormorant text-[1.2rem] font-light text-espresso leading-[1.8] space-y-4">
<p>
Ich bin fest davon überzeugt, dass jede Frau wunderschön ist genau so, wie sie ist.
Ein Boudoir-Shooting ist so viel mehr als nur Fotos. Es ist eine Reise zu dir selbst,
ein Moment, in dem du dich fallen lassen und deine eigene Schönheit neu entdecken kannst.
</p>
<p>
In meinem Studio schaffe ich einen geschützten Raum, in dem du dich wohl und sicher fühlst.
Mit viel Einfühlungsvermögen und professioneller Anleitung entstehen Bilder,
die deine einzigartige Persönlichkeit und Stärke zeigen.
</p>
</div>
<p className="font-playfair italic text-bordeaux mt-8 text-lg">
Die Fotografin
</p>
<a
href="/ueber-mich"
className="btn-cta inline-block mt-8 text-espresso border-espresso/20 hover:border-bordeaux hover:text-bordeaux"
>
Mehr erfahren
</a>
</div>
</div>
</ScrollReveal>
</div>
</section>
)
}

View file

@ -1,64 +0,0 @@
import { ScrollReveal } from "@/components/ScrollReveal"
export function BlogPreview() {
// Static placeholders until real posts are seeded
const posts = [
{
title: "Warum jede Frau ein Boudoir-Shooting erleben sollte",
date: "15. Februar 2026",
excerpt: "Ein Boudoir-Shooting ist weit mehr als schoene Fotos — es ist eine Reise der Selbstentdeckung und ein kraftvolles Statement der Selbstliebe.",
},
{
title: "Behind the Scenes: So entsteht die perfekte Atmosphaere",
date: "10. Februar 2026",
excerpt: "Von der Musikauswahl bis zur Lichtgestaltung — ein Blick hinter die Kulissen meines Studios und wie ich den perfekten Rahmen schaffe.",
},
{
title: "5 Tipps fuer dein erstes Boudoir-Shooting",
date: "5. Februar 2026",
excerpt: "Du ueberlegst, ein Boudoir-Shooting zu machen? Hier sind meine besten Tipps, damit du dich optimal vorbereitest und den Moment geniesst.",
},
]
return (
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
<ScrollReveal>
<div className="text-center mb-16">
<span className="section-label text-espresso block mb-4">Journal</span>
<h2 className="font-playfair text-[clamp(2rem,4vw,3.2rem)] text-bordeaux mb-4">
Gedanken & <em>Geschichten</em>
</h2>
<div className="divider-line mx-auto" />
</div>
</ScrollReveal>
<div className="grid grid-cols-1 min-[901px]:grid-cols-3 gap-8">
{posts.map((post, i) => (
<ScrollReveal key={i}>
<article className="group cursor-pointer">
{/* Image placeholder */}
<div className="aspect-[4/3] bg-blush-soft mb-6" />
<p className="font-josefin text-[0.55rem] font-light uppercase tracking-[0.15em] text-espresso/40 mb-2">
{post.date}
</p>
<h3 className="font-playfair text-[1.2rem] text-espresso group-hover:text-bordeaux transition-colors duration-300 mb-3">
{post.title}
</h3>
<p className="font-cormorant text-[1rem] font-light text-espresso/70 leading-[1.7]">
{post.excerpt}
</p>
</article>
</ScrollReveal>
))}
</div>
<div className="text-center mt-12">
<a href="/journal" className="btn-cta inline-block text-espresso border-espresso/20 hover:border-bordeaux hover:text-bordeaux">
Alle Beitraege lesen
</a>
</div>
</div>
</section>
)
}

View file

@ -1,64 +0,0 @@
import { ScrollReveal } from "@/components/ScrollReveal"
export function GalleryPreview() {
// Placeholder categories for the asymmetric grid
const images = [
{ category: "Klassisch" },
{ category: "Elegant" },
{ category: "Artistisch" },
{ category: "Natürlich" },
{ category: "Sinnlich" },
{ category: "Dramatisch" },
{ category: "Klassisch" },
{ category: "Elegant" },
]
return (
<section className="bg-dark-wine py-[120px] max-[900px]:py-20">
<div className="max-w-[1400px] mx-auto px-6">
<ScrollReveal>
<div className="text-center mb-16">
<span className="section-label text-blush block mb-4">Portfolio</span>
<h2 className="font-playfair text-[clamp(2rem,4vw,3.2rem)] text-blush mb-4">
Momente der <em>Selbstliebe</em>
</h2>
<div className="divider-line mx-auto mb-6" />
<p className="font-cormorant text-[1.1rem] font-light text-blush/60 max-w-xl mx-auto">
Jedes Shooting erzählt eine einzigartige Geschichte von Stärke, Anmut und Selbstliebe.
</p>
</div>
</ScrollReveal>
<ScrollReveal>
{/* Asymmetric grid */}
<div className="grid grid-cols-2 min-[901px]:grid-cols-4 gap-1">
{images.map((img, i) => (
<div
key={i}
className={
"group relative overflow-hidden bg-blush-soft "
+ (i === 2 ? "min-[901px]:row-span-2 " : "")
+ (i === 5 ? "min-[901px]:col-span-2 " : "")
+ (i === 2 ? "aspect-[3/4] min-[901px]:aspect-auto" : "aspect-[3/4]")
}
>
{/* Hover overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-dark-wine/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-400 flex items-end p-4">
<span className="font-josefin text-[0.55rem] font-light uppercase tracking-[0.2em] text-blush opacity-0 group-hover:opacity-100 transition-opacity duration-400 delay-100">
{img.category}
</span>
</div>
</div>
))}
</div>
</ScrollReveal>
<div className="text-center mt-12">
<a href="/galerie" className="btn-cta inline-block text-blush">
Alle Arbeiten ansehen
</a>
</div>
</div>
</section>
)
}

View file

@ -1,58 +0,0 @@
"use client"
import { useEffect, useState } from "react"
export function Hero() {
const [visible, setVisible] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setVisible(true), 100)
return () => clearTimeout(timer)
}, [])
return (
<section className="relative min-h-screen flex items-center justify-center bg-dark-wine overflow-hidden">
{/* Radial gradient overlays */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_20%_50%,rgba(139,58,74,0.15),transparent_70%)]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_80%_50%,rgba(212,169,160,0.08),transparent_70%)]" />
<div
className={"text-center px-6 transition-all duration-[1.2s] ease-out "
+ (visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8")}
>
{/* Decorative line */}
<div className="w-[60px] h-px mx-auto mb-10 bg-gradient-to-r from-transparent via-blush to-transparent" />
<h1 className="font-playfair text-blush">
<span className="block text-[clamp(3.5rem,8vw,7rem)] font-normal leading-[1.05]">
Sensual
</span>
<span className="block text-[clamp(2.5rem,6vw,5rem)] italic font-normal -mt-2">
Moment
</span>
</h1>
<p className="font-josefin text-[0.6rem] font-light uppercase tracking-[0.4em] text-blush/50 mt-6">
Boudoir Photography · Dein Moment der Selbstliebe
</p>
<div className="mt-12">
<a
href="/kontakt"
className="btn-cta inline-block text-blush hover:text-blush"
>
Dein Shooting buchen
</a>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 animate-pulse">
<span className="font-josefin text-[0.5rem] font-light uppercase tracking-[0.2em] text-blush/30">
Scroll
</span>
<div className="w-px h-8 bg-gradient-to-b from-blush/40 to-transparent" />
</div>
</section>
)
}

View file

@ -1,118 +0,0 @@
import { ScrollReveal } from "@/components/ScrollReveal"
interface Package {
name: string
price: string
features: string[]
featured?: boolean
}
const packages: Package[] = [
{
name: "Entdecken",
price: "Ab 349 EUR",
features: [
"1-2 Stunden Shooting",
"Professionelles Styling-Beratung",
"10 bearbeitete Digitalbilder",
"Private Online-Galerie",
"Persoenliche Bildauswahl",
],
},
{
name: "Erleben",
price: "Ab 599 EUR",
featured: true,
features: [
"2-3 Stunden Shooting",
"Professionelles Hair & Make-up",
"25 bearbeitete Digitalbilder",
"Private Online-Galerie",
"1 Fine-Art Print (30x40)",
"Outfitwechsel inklusive",
],
},
{
name: "Zelebrieren",
price: "Ab 899 EUR",
features: [
"3-4 Stunden Shooting",
"Professionelles Hair & Make-up",
"Alle bearbeiteten Digitalbilder",
"Premium Fine-Art Album",
"3 Fine-Art Prints",
"Champagner & Verwoehn-Paket",
],
},
]
export function Packages() {
return (
<section className="bg-navy py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
<ScrollReveal>
<div className="text-center mb-16">
<span className="section-label text-blush block mb-4">Investition in dich</span>
<h2 className="font-playfair text-[clamp(2rem,4vw,3.2rem)] text-blush mb-4">
Pakete & <em>Preise</em>
</h2>
<div className="divider-line mx-auto" />
</div>
</ScrollReveal>
<div className="grid grid-cols-1 min-[901px]:grid-cols-3 gap-8">
{packages.map((pkg, i) => (
<ScrollReveal key={i}>
<div
className={
"relative p-8 border transition-all duration-400 hover:-translate-y-1 hover:shadow-xl "
+ (pkg.featured
? "border-blush bg-white/5"
: "border-blush-border hover:border-blush/50")
}
>
{pkg.featured && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="bg-blush text-dark-wine font-josefin text-[0.5rem] font-light uppercase tracking-[0.15em] px-4 py-1.5 rounded-full">
Beliebtestes Paket
</span>
</div>
)}
<h3 className="font-playfair text-[1.4rem] text-blush text-center mb-2 mt-4">
{pkg.name}
</h3>
<p className="font-josefin text-[0.7rem] font-light uppercase tracking-[0.15em] text-blush/60 text-center mb-8">
{pkg.price}
</p>
<ul className="space-y-3 mb-8">
{pkg.features.map((f, j) => (
<li key={j} className="font-cormorant text-[0.95rem] font-light text-creme/70 flex items-start gap-3">
<span className="text-blush/60 mt-0.5">&#10003;</span>
{f}
</li>
))}
</ul>
<div className="text-center">
<a
href="/kontakt"
className={
"btn-cta inline-block "
+ (pkg.featured
? "bg-blush text-dark-wine border-blush hover:bg-[#E0B8B0] hover:shadow-lg"
: "text-blush")
}
>
Jetzt anfragen
</a>
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
)
}

View file

@ -1,58 +0,0 @@
import { ScrollReveal } from "@/components/ScrollReveal"
interface Testimonial {
id?: number
quote: string
author: string
role?: string
}
interface TestimonialsProps {
testimonials: Testimonial[]
}
export function Testimonials({ testimonials }: TestimonialsProps) {
if (!testimonials.length) return null
return (
<section className="bg-creme py-[120px] max-[900px]:py-20">
<div className="max-w-[1280px] mx-auto px-6">
<ScrollReveal>
<div className="text-center mb-16">
<span className="section-label text-espresso block mb-4">Erfahrungen</span>
<h2 className="font-playfair text-[clamp(2rem,4vw,3.2rem)] text-bordeaux mb-4">
Was meine Kundinnen <em>sagen</em>
</h2>
<div className="divider-line mx-auto" />
</div>
</ScrollReveal>
<div className="grid grid-cols-1 min-[901px]:grid-cols-3 gap-8">
{testimonials.slice(0, 3).map((t, i) => (
<ScrollReveal key={t.id || i}>
<div className="bg-white p-8 border border-blush-border hover:shadow-lg hover:-translate-y-1 transition-all duration-400">
{/* Decorative quote mark */}
<span className="font-playfair text-[2rem] text-blush leading-none block mb-4">
&ldquo;
</span>
<p className="font-cormorant text-[1.05rem] font-light italic text-espresso leading-[1.75] mb-6">
{t.quote}
</p>
<div>
<p className="font-josefin text-[0.6rem] font-light uppercase tracking-[0.2em] text-bordeaux">
{t.author}
</p>
{t.role && (
<p className="font-cormorant text-[0.85rem] font-light text-espresso/50 mt-1">
{t.role}
</p>
)}
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
)
}