From b63d3bdb808d56f26e4ab01f4acb2d1e468cfb20 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Sat, 28 Feb 2026 13:06:50 +0000 Subject: [PATCH] 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 --- src/app/agb/page.tsx | 47 +------ src/app/datenschutz/page.tsx | 45 +------ src/app/faq/page.tsx | 65 +--------- src/app/galerie/page.tsx | 76 ++--------- src/app/impressum/page.tsx | 46 +------ src/app/journal/[slug]/page.tsx | 35 +++++- src/app/journal/page.tsx | 57 +-------- src/app/kontakt/page.tsx | 23 ++-- src/app/page.tsx | 26 +--- src/app/pakete/page.tsx | 60 +-------- src/app/ueber-mich/page.tsx | 92 ++------------ src/components/blocks/BlockRenderer.tsx | 55 ++++++++ src/components/blocks/CTABlock.tsx | 53 ++++++++ src/components/blocks/CardGridBlock.tsx | 55 ++++++++ .../ContactFormBlock.tsx} | 64 ++++++---- .../blocks}/FAQAccordion.tsx | 5 +- src/components/blocks/FAQBlock.tsx | 52 ++++++++ src/components/blocks/HeroBlock.tsx | 97 ++++++++++++++ src/components/blocks/ImageTextBlock.tsx | 69 ++++++++++ src/components/blocks/PostsListBlock.tsx | 81 ++++++++++++ src/components/blocks/PricingBlock.tsx | 81 ++++++++++++ src/components/blocks/RichText.tsx | 111 ++++++++++++++++ src/components/blocks/TestimonialsBlock.tsx | 63 ++++++++++ src/components/blocks/TextBlock.tsx | 27 ++++ src/components/sections/AboutPreview.tsx | 47 ------- src/components/sections/BlogPreview.tsx | 64 ---------- src/components/sections/GalleryPreview.tsx | 64 ---------- src/components/sections/Hero.tsx | 58 --------- src/components/sections/Packages.tsx | 118 ------------------ src/components/sections/Testimonials.tsx | 58 --------- 30 files changed, 877 insertions(+), 917 deletions(-) create mode 100644 src/components/blocks/BlockRenderer.tsx create mode 100644 src/components/blocks/CTABlock.tsx create mode 100644 src/components/blocks/CardGridBlock.tsx rename src/components/{sections/Contact.tsx => blocks/ContactFormBlock.tsx} (76%) rename src/{app/faq => components/blocks}/FAQAccordion.tsx (90%) create mode 100644 src/components/blocks/FAQBlock.tsx create mode 100644 src/components/blocks/HeroBlock.tsx create mode 100644 src/components/blocks/ImageTextBlock.tsx create mode 100644 src/components/blocks/PostsListBlock.tsx create mode 100644 src/components/blocks/PricingBlock.tsx create mode 100644 src/components/blocks/RichText.tsx create mode 100644 src/components/blocks/TestimonialsBlock.tsx create mode 100644 src/components/blocks/TextBlock.tsx delete mode 100644 src/components/sections/AboutPreview.tsx delete mode 100644 src/components/sections/BlogPreview.tsx delete mode 100644 src/components/sections/GalleryPreview.tsx delete mode 100644 src/components/sections/Hero.tsx delete mode 100644 src/components/sections/Packages.tsx delete mode 100644 src/components/sections/Testimonials.tsx diff --git a/src/app/agb/page.tsx b/src/app/agb/page.tsx index f4f0815..412ad6b 100644 --- a/src/app/agb/page.tsx +++ b/src/app/agb/page.tsx @@ -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 ( - <> -
-

- Allgemeine Geschaeftsbedingungen -

-
-
-
- -
- {paragraphs.length > 0 ? ( - paragraphs.map((p, i) =>

{p}

) - ) : ( -

AGB werden in Kuerze ergaenzt.

- )} -
-
-
-
- - ) + return } diff --git a/src/app/datenschutz/page.tsx b/src/app/datenschutz/page.tsx index a69f659..d9b64c9 100644 --- a/src/app/datenschutz/page.tsx +++ b/src/app/datenschutz/page.tsx @@ -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 ( - <> -
-

Datenschutz

-
-
-
- -
- {paragraphs.length > 0 ? ( - paragraphs.map((p, i) =>

{p}

) - ) : ( -

Datenschutzerklaerung wird in Kuerze ergaenzt.

- )} -
-
-
-
- - ) + return } diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx index 95fdeec..4c3b7b3 100644 --- a/src/app/faq/page.tsx +++ b/src/app/faq/page.tsx @@ -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 = { - "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) + const page = await fetchPage("faq") + if (!page) notFound() - return ( - <> - {/* Hero */} -
- Haeufige Fragen -

- FAQ -

-
-
- - {/* FAQ groups */} -
-
- {Object.entries(grouped).map(([category, faqs]) => ( - -

- {categoryLabels[category] || category} -

-
- - - ))} -
-
- - {/* CTA */} -
- -

- Noch Fragen? -

-

- Schreib mir gerne — ich beantworte jede Frage persoenlich. -

- - Kontakt aufnehmen - -
-
- - ) + return } diff --git a/src/app/galerie/page.tsx b/src/app/galerie/page.tsx index eae3555..aec7a14 100644 --- a/src/app/galerie/page.tsx +++ b/src/app/galerie/page.tsx @@ -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 */} -
- Portfolio -

- Momente der Selbstliebe -

-
-
- - {/* Gallery grid */} -
-
- {/* Filter tabs */} - -
- {categories.map((cat) => ( - - ))} -
-
- - {/* Masonry-like grid */} -
- {images.map((img, i) => ( - -
-
- - {img.category} - -
-
-
- ))} -
-
-
- - {/* CTA */} -
- -

- Bereit fuer deine eigene Geschichte? -

- - Shooting anfragen - -
-
- - ) + return } diff --git a/src/app/impressum/page.tsx b/src/app/impressum/page.tsx index 2b8cc58..487fec9 100644 --- a/src/app/impressum/page.tsx +++ b/src/app/impressum/page.tsx @@ -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 ( - <> -
-

Impressum

-
-
-
- -
- {paragraphs.length > 0 ? ( - paragraphs.map((p, i) =>

{p}

) - ) : ( -

Impressum wird in Kuerze ergaenzt.

- )} -
-
-
-
- - ) + return } diff --git a/src/app/journal/[slug]/page.tsx b/src/app/journal/[slug]/page.tsx index f4390f0..a5e8b7c 100644 --- a/src/app/journal/[slug]/page.tsx +++ b/src/app/journal/[slug]/page.tsx @@ -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<{ + {/* Cover Image */} + {coverUrl && ( +
+
+ {post.coverImage?.alt +
+
+ )} + {/* Content */}
-
- {post.excerpt &&

{post.excerpt}

} -

Vollstaendiger Inhalt folgt in Kuerze.

-
+ {post.excerpt && ( +

+ {post.excerpt} +

+ )} + {post.content ? ( + + ) : ( +

+ Vollstaendiger Inhalt folgt in Kuerze. +

+ )}
diff --git a/src/app/journal/page.tsx b/src/app/journal/page.tsx index cb7e683..1d4ef58 100644 --- a/src/app/journal/page.tsx +++ b/src/app/journal/page.tsx @@ -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 */} -
- Journal -

- Gedanken & Geschichten -

-
-
- - {/* Posts grid */} -
-
- {posts.length > 0 ? ( -
- {posts.map((post, i) => ( - - - - ) : ( -
-

- Bald findest du hier inspirierende Beitraege rund um Boudoir-Fotografie und Selbstliebe. -

-
- )} -
-
- - ) + return } diff --git a/src/app/kontakt/page.tsx b/src/app/kontakt/page.tsx index 0ca5dea..58cc495 100644 --- a/src/app/kontakt/page.tsx +++ b/src/app/kontakt/page.tsx @@ -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 */} -
- Kontakt -

- Schreib mir -

-
-
+export default async function KontaktPage() { + const page = await fetchPage("kontakt") + if (!page) notFound() - {/* Contact section */} - - - ) + return } diff --git a/src/app/page.tsx b/src/app/page.tsx index fe57086..c8b6eab 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( - <> - - - - - - - - - ) + return } diff --git a/src/app/pakete/page.tsx b/src/app/pakete/page.tsx index 52d8f71..de63838 100644 --- a/src/app/pakete/page.tsx +++ b/src/app/pakete/page.tsx @@ -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 */} -
- Investition in dich -

- Pakete & Preise -

-
-

- Jedes Paket ist darauf ausgelegt, dir ein unvergessliches Erlebnis zu schenken. - Individuelle Anpassungen sind jederzeit moeglich. -

-
- - {/* Packages */} - - - {/* FAQ section */} - {faqs.length > 0 && ( -
-
- -
- Haeufige Fragen -

- Zu Kosten & Buchung -

-
-
- - -
-
- )} - - {/* CTA */} -
- -

- Bereit fuer deinen Moment? -

- - Jetzt Shooting buchen - -
-
- - ) + return } diff --git a/src/app/ueber-mich/page.tsx b/src/app/ueber-mich/page.tsx index 5ce7645..d55a27b 100644 --- a/src/app/ueber-mich/page.tsx +++ b/src/app/ueber-mich/page.tsx @@ -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 */} -
-
-
-
- Ueber mich -

- Die Frau hinter der Kamera -

-
-

- Ich bin leidenschaftliche Fotografin und glaube fest daran, - dass jede Frau eine Geschichte hat, die es wert ist, erzaehlt zu werden. -

-
-
-
+export default async function UeberMichPage() { + const page = await fetchPage("ueber-mich") + if (!page) notFound() - {/* Story */} -
-
- -

- Meine Geschichte -

-
-
-

- 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. -

-

- 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. -

-

- Ich arbeite mit natuerlichem Licht und einer ruhigen, achtsamen Atmosphaere. - Keine gestellten Posen, keine Perfektion — nur du, in deiner authentischsten Form. -

-
- -
-
- - {/* Values */} -
-
- -

- Meine Werte -

-
- {[ - { 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) => ( -
-

{v.title}

-

{v.text}

-
- ))} -
-
-
-
- - {/* CTA */} -
- -

- Lass uns kennenlernen -

- - Kontakt aufnehmen - -
-
- - ) + return } diff --git a/src/components/blocks/BlockRenderer.tsx b/src/components/blocks/BlockRenderer.tsx new file mode 100644 index 0000000..d687940 --- /dev/null +++ b/src/components/blocks/BlockRenderer.tsx @@ -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 + case "text-block": + return + case "testimonials-block": + return + case "pricing": + return + case "cta-block": + return + case "card-grid-block": + return + case "posts-list-block": + return + case "contact-form-block": + return + case "faq-block": + return + case "image-text-block": + return + default: + if (process.env.NODE_ENV === "development") { + console.warn("Unknown block type:", block.blockType) + } + return null + } + })} + + ) +} diff --git a/src/components/blocks/CTABlock.tsx b/src/components/blocks/CTABlock.tsx new file mode 100644 index 0000000..5ed15cf --- /dev/null +++ b/src/components/blocks/CTABlock.tsx @@ -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 = { + 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 ( +
+
+ + {block.headline && ( +

+ {block.headline} +

+ )} + {block.description && ( +

+ {block.description} +

+ )} + {block.buttons?.length ? ( +
+ {block.buttons.map((btn, i) => ( + + {btn.text} + + ))} +
+ ) : null} +
+
+
+ ) +} diff --git a/src/components/blocks/CardGridBlock.tsx b/src/components/blocks/CardGridBlock.tsx new file mode 100644 index 0000000..82a6438 --- /dev/null +++ b/src/components/blocks/CardGridBlock.tsx @@ -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 ( +
+
+ {block.headline && ( + +
+

+ {block.headline} +

+
+
+ + )} +
+ {cards.map((card: any, i: number) => ( + +
+

+ {card.title} +

+ {card.description && ( +

+ {card.description} +

+ )} + {card.link && card.linkText && ( + + {card.linkText} + + )} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/components/sections/Contact.tsx b/src/components/blocks/ContactFormBlock.tsx similarity index 76% rename from src/components/sections/Contact.tsx rename to src/components/blocks/ContactFormBlock.tsx index 1c5c3cb..376ba28 100644 --- a/src/components/sections/Contact.tsx +++ b/src/components/blocks/ContactFormBlock.tsx @@ -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() {
Kontakt

- Bereit fuer deinen Moment? + {block.headline ? ( + block.headline.includes("Moment") ? ( + <>{block.headline.split("Moment")[0]}Moment{block.headline.split("Moment")[1]} + ) : block.headline + ) : ( + <>Bereit fuer deinen Moment? + )}

-

- Ich freue mich darauf, dich kennenzulernen und gemeinsam deinen - ganz persoenlichen Moment zu gestalten. Schreib mir oder ruf mich an — - unverbindlich und vertraulich. -

-
-

- E-Mail - info@sensualmoment.de + {block.description && ( +

+ {block.description}

-

- Telefon - +49 123 456 7890 -

-

- Studio - Musterstrasse 42, 12345 Musterstadt -

-
+ )} + {block.showContactInfo !== false && ( +
+

+ E-Mail + info@sensualmoment.de +

+

+ Telefon + +49 123 456 7890 +

+

+ Studio + Musterstrasse 42, 12345 Musterstadt +

+
+ )}
@@ -82,7 +102,7 @@ export function Contact() {

- Vielen Dank! + {block.successMessage || "Vielen Dank!"}

Deine Nachricht ist angekommen. Ich melde mich innerhalb von 24 Stunden bei dir. diff --git a/src/app/faq/FAQAccordion.tsx b/src/components/blocks/FAQAccordion.tsx similarity index 90% rename from src/app/faq/FAQAccordion.tsx rename to src/components/blocks/FAQAccordion.tsx index 9351bfe..3eadcde 100644 --- a/src/app/faq/FAQAccordion.tsx +++ b/src/components/blocks/FAQAccordion.tsx @@ -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(null) +export function FAQAccordion({ faqs, expandFirst = false }: FAQAccordionProps) { + const [openIndex, setOpenIndex] = useState(expandFirst ? 0 : null) return (

diff --git a/src/components/blocks/FAQBlock.tsx b/src/components/blocks/FAQBlock.tsx new file mode 100644 index 0000000..b4dd66d --- /dev/null +++ b/src/components/blocks/FAQBlock.tsx @@ -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 = { + light: "bg-creme", + dark: "bg-dark-wine", + white: "bg-white", + } + const bg = bgMap[block.backgroundColor || "light"] || bgMap.light + const isDark = block.backgroundColor === "dark" + + return ( +
+
+ {block.title && ( + +
+

+ {block.title} +

+
+
+ + )} + + + +
+
+ ) +} diff --git a/src/components/blocks/HeroBlock.tsx b/src/components/blocks/HeroBlock.tsx new file mode 100644 index 0000000..634d857 --- /dev/null +++ b/src/components/blocks/HeroBlock.tsx @@ -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 ( +
+ {/* Gradient overlays */} + {block.overlay !== false && ( + <> +
+
+ {bgUrl &&
} + + )} + +
+ {/* Decorative line */} +
+ + {isHomeHero && headlineParts.length >= 2 ? ( +

+ + {headlineParts[0]} + + + {headlineParts.slice(1).join(" ")} + +

+ ) : ( +

+ {block.headline} +

+ )} + + {block.subline && ( +

+ {block.subline} +

+ )} + + {block.cta?.text && ( + + )} +
+ + {/* Scroll indicator (only on first/full-screen hero) */} + {isFirst && ( +
+ Scroll +
+
+ )} +
+ ) +} diff --git a/src/components/blocks/ImageTextBlock.tsx b/src/components/blocks/ImageTextBlock.tsx new file mode 100644 index 0000000..1f24542 --- /dev/null +++ b/src/components/blocks/ImageTextBlock.tsx @@ -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 ( +
+
+
+ {/* Image */} + + {imageUrl ? ( +
+ {imgObj?.alt +
+ ) : ( +
+ )} + + + {/* Text */} + +
+ {block.headline && ( + <> + + {block.imagePosition === "left" ? "Philosophie" : "Ueber mich"} + +

+ {block.headline} +

+
+ + )} + + {block.cta?.text && ( + + {block.cta.text} + + )} +
+ +
+
+
+ ) +} diff --git a/src/components/blocks/PostsListBlock.tsx b/src/components/blocks/PostsListBlock.tsx new file mode 100644 index 0000000..180e7d0 --- /dev/null +++ b/src/components/blocks/PostsListBlock.tsx @@ -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 ( +
+
+ {block.title && ( + +
+ Journal +

+ {block.title} +

+
+
+ + )} +
+ {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 ( + + + +
+
+ ) +} diff --git a/src/components/blocks/PricingBlock.tsx b/src/components/blocks/PricingBlock.tsx new file mode 100644 index 0000000..a3fb181 --- /dev/null +++ b/src/components/blocks/PricingBlock.tsx @@ -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 ( +
+
+ {block.title && ( + +
+ Investition +

+ {block.title} +

+ {block.description && ( +

+ {block.description} +

+ )} +
+
+ + )} +
+ {plans.map((plan: any, i: number) => ( + +
+ {plan.isPopular && plan.popularLabel && ( +
+ + {plan.popularLabel} + +
+ )} +

{plan.name}

+

+ {before ? currency : ""}{plan.price}{before ? "" : currency} +

+
    + {plan.features?.map((f: any, j: number) => ( +
  • + + {typeof f === "string" ? f : f.feature || f.text} +
  • + ))} +
+ {plan.ctaText && ( + + {plan.ctaText} + + )} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/components/blocks/RichText.tsx b/src/components/blocks/RichText.tsx new file mode 100644 index 0000000..4297e58 --- /dev/null +++ b/src/components/blocks/RichText.tsx @@ -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 = {el} + if (format & 2) el = {el} + if (format & 4) el = {el} + if (format & 8) el = {el} + if (format & 16) el = {el} + + return el +} + +function renderNode(node: any, index: number): ReactNode { + if (!node) return null + + if (node.type === "text") { + return {renderText(node)} + } + + if (node.type === "linebreak") { + return
+ } + + if (node.type === "link") { + const url = node.fields?.url || node.url || "#" + const newTab = node.fields?.newTab || node.newTab + return ( + + {node.children?.map((child: any, i: number) => renderNode(child, i))} + + ) + } + + const children = node.children?.map((child: any, i: number) => renderNode(child, i)) + + if (node.type === "heading") { + const tag = node.tag || "h2" + const sizes: Record = { + 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

{children}

+ case "h2": return

{children}

+ case "h3": return

{children}

+ case "h4": return

{children}

+ case "h5": return
{children}
+ case "h6": return
{children}
+ default: return

{children}

+ } + } + + if (node.type === "paragraph") { + return ( +

+ {children} +

+ ) + } + + if (node.type === "list") { + const Tag = node.listType === "number" ? "ol" : "ul" + return ( + + {children} + + ) + } + + if (node.type === "listitem") { + return
  • {children}
  • + } + + // 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 ( +
    + {root.children.map((node: any, i: number) => renderNode(node, i))} +
    + ) +} diff --git a/src/components/blocks/TestimonialsBlock.tsx b/src/components/blocks/TestimonialsBlock.tsx new file mode 100644 index 0000000..925238d --- /dev/null +++ b/src/components/blocks/TestimonialsBlock.tsx @@ -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 ( +
    +
    + {block.title && ( + +
    + Kundenstimmen +

    + {block.title} +

    +
    +
    + + )} +
    + {items.map((t: any, i: number) => ( + +
    +
    +

    + {t.quote} +

    +
    +

    + {t.author} +

    + {t.role && ( +

    + {t.role} +

    + )} +
    +
    +
    + ))} +
    +
    +
    + ) +} diff --git a/src/components/blocks/TextBlock.tsx b/src/components/blocks/TextBlock.tsx new file mode 100644 index 0000000..d7c720b --- /dev/null +++ b/src/components/blocks/TextBlock.tsx @@ -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 = { + 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 ( +
    +
    + +
    +
    + ) +} diff --git a/src/components/sections/AboutPreview.tsx b/src/components/sections/AboutPreview.tsx deleted file mode 100644 index 3a25752..0000000 --- a/src/components/sections/AboutPreview.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ScrollReveal } from "@/components/ScrollReveal" - -export function AboutPreview() { - return ( -
    -
    - -
    - {/* Image placeholder */} -
    - - {/* Text */} -
    - Über mich -

    - Jede Frau verdient es,{" "} - sich selbst zu feiern -

    -
    -
    -

    - 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. -

    -

    - 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. -

    -
    -

    - — Die Fotografin -

    - - Mehr erfahren - -
    -
    - -
    -
    - ) -} diff --git a/src/components/sections/BlogPreview.tsx b/src/components/sections/BlogPreview.tsx deleted file mode 100644 index 9a48bc7..0000000 --- a/src/components/sections/BlogPreview.tsx +++ /dev/null @@ -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 ( -
    -
    - -
    - Journal -

    - Gedanken & Geschichten -

    -
    -
    - - -
    - {posts.map((post, i) => ( - -
    - {/* Image placeholder */} -
    -

    - {post.date} -

    -

    - {post.title} -

    -

    - {post.excerpt} -

    -
    -
    - ))} -
    - - -
    -
    - ) -} diff --git a/src/components/sections/GalleryPreview.tsx b/src/components/sections/GalleryPreview.tsx deleted file mode 100644 index 426796d..0000000 --- a/src/components/sections/GalleryPreview.tsx +++ /dev/null @@ -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 ( -
    -
    - -
    - Portfolio -

    - Momente der Selbstliebe -

    -
    -

    - Jedes Shooting erzählt eine einzigartige Geschichte von Stärke, Anmut und Selbstliebe. -

    -
    - - - - {/* Asymmetric grid */} -
    - {images.map((img, i) => ( -
    - {/* Hover overlay */} -
    - - {img.category} - -
    -
    - ))} -
    -
    - - -
    -
    - ) -} diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx deleted file mode 100644 index 2d14211..0000000 --- a/src/components/sections/Hero.tsx +++ /dev/null @@ -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 ( -
    - {/* Radial gradient overlays */} -
    -
    - -
    - {/* Decorative line */} -
    - -

    - - Sensual - - - Moment - -

    - -

    - Boudoir Photography · Dein Moment der Selbstliebe -

    - - -
    - - {/* Scroll indicator */} -
    - - Scroll - -
    -
    -
    - ) -} diff --git a/src/components/sections/Packages.tsx b/src/components/sections/Packages.tsx deleted file mode 100644 index 17992ca..0000000 --- a/src/components/sections/Packages.tsx +++ /dev/null @@ -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 ( -
    -
    - -
    - Investition in dich -

    - Pakete & Preise -

    -
    -
    - - -
    - {packages.map((pkg, i) => ( - -
    - {pkg.featured && ( -
    - - Beliebtestes Paket - -
    - )} - -

    - {pkg.name} -

    -

    - {pkg.price} -

    - -
      - {pkg.features.map((f, j) => ( -
    • - - {f} -
    • - ))} -
    - - -
    -
    - ))} -
    -
    -
    - ) -} diff --git a/src/components/sections/Testimonials.tsx b/src/components/sections/Testimonials.tsx deleted file mode 100644 index 19a0cb5..0000000 --- a/src/components/sections/Testimonials.tsx +++ /dev/null @@ -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 ( -
    -
    - -
    - Erfahrungen -

    - Was meine Kundinnen sagen -

    -
    -
    - - -
    - {testimonials.slice(0, 3).map((t, i) => ( - -
    - {/* Decorative quote mark */} - - “ - -

    - {t.quote} -

    -
    -

    - {t.author} -

    - {t.role && ( -

    - {t.role} -

    - )} -
    -
    -
    - ))} -
    -
    -
    - ) -}