mirror of
https://github.com/complexcaresolutions/frontend.sensualmoment.de.git
synced 2026-03-17 13:53:53 +00:00
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:
parent
95c8f1e1ed
commit
b63d3bdb80
30 changed files with 877 additions and 917 deletions
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]} />
|
||||
}
|
||||
|
|
|
|||
55
src/components/blocks/BlockRenderer.tsx
Normal file
55
src/components/blocks/BlockRenderer.tsx
Normal 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
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
53
src/components/blocks/CTABlock.tsx
Normal file
53
src/components/blocks/CTABlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
src/components/blocks/CardGridBlock.tsx
Normal file
55
src/components/blocks/CardGridBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,28 +63,36 @@ 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" />
|
||||
<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.
|
||||
</p>
|
||||
<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>
|
||||
info@sensualmoment.de
|
||||
{block.description && (
|
||||
<p className="font-cormorant text-[1.1rem] font-light text-blush/70 leading-[1.8] mb-8">
|
||||
{block.description}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-josefin text-[0.55rem] uppercase tracking-[0.2em] text-blush/40 block mb-1">Telefon</span>
|
||||
+49 123 456 7890
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-josefin text-[0.55rem] uppercase tracking-[0.2em] text-blush/40 block mb-1">Studio</span>
|
||||
Musterstrasse 42, 12345 Musterstadt
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
info@sensualmoment.de
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-josefin text-[0.55rem] uppercase tracking-[0.2em] text-blush/40 block mb-1">Telefon</span>
|
||||
+49 123 456 7890
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-josefin text-[0.55rem] uppercase tracking-[0.2em] text-blush/40 block mb-1">Studio</span>
|
||||
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.
|
||||
|
|
@ -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">
|
||||
52
src/components/blocks/FAQBlock.tsx
Normal file
52
src/components/blocks/FAQBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/components/blocks/HeroBlock.tsx
Normal file
97
src/components/blocks/HeroBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
src/components/blocks/ImageTextBlock.tsx
Normal file
69
src/components/blocks/ImageTextBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
src/components/blocks/PostsListBlock.tsx
Normal file
81
src/components/blocks/PostsListBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
src/components/blocks/PricingBlock.tsx
Normal file
81
src/components/blocks/PricingBlock.tsx
Normal 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">✓</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>
|
||||
)
|
||||
}
|
||||
111
src/components/blocks/RichText.tsx
Normal file
111
src/components/blocks/RichText.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
src/components/blocks/TestimonialsBlock.tsx
Normal file
63
src/components/blocks/TestimonialsBlock.tsx
Normal 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">“</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>
|
||||
)
|
||||
}
|
||||
27
src/components/blocks/TextBlock.tsx
Normal file
27
src/components/blocks/TextBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">✓</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
“
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue