feat: complete frontend scaffold with all pages and components

- Project foundation: Next.js 16, Tailwind v4, Google Fonts, payload-contracts
- Shared components: Navigation (scroll effect), Footer (Deep Navy), Logo (wordmark), ScrollReveal
- Homepage: Hero, AboutPreview, GalleryPreview, Testimonials, Packages, BlogPreview, Contact
- Inner pages: ueber-mich, galerie, pakete, journal, journal/[slug], kontakt, faq, impressum, datenschutz, agb
- CMS API client (src/lib/api.ts) with tenant-scoped fetch helpers
- server.js for Plesk Passenger deployment
- Color palette: Dark Wine, Blush, Bordeaux, Deep Navy, Creme, Espresso
- Fonts: Playfair Display (headlines), Cormorant Garamond (body), Josefin Sans (UI)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-27 12:57:57 +00:00
parent c0fa78cf07
commit f4e610e81e
43 changed files with 6053 additions and 2 deletions

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -1,2 +1,36 @@
# frontend.sensualmoment.de This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
Frontend for sensualmoment.de - Next.js with Payload CMS
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

13
next.config.ts Normal file
View file

@ -0,0 +1,13 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
transpilePackages: ["@c2s/payload-contracts"],
images: {
remotePatterns: [
{ protocol: "https", hostname: "pl.porwoll.tech" },
{ protocol: "https", hostname: "cms.c2sgmbh.de" },
],
},
}
export default nextConfig

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "frontend.sensualmoment.de",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@c2s/payload-contracts": "github:complexcaresolutions/payload-contracts",
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}

4023
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- "@c2s/payload-contracts"

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

30
server.js Normal file
View file

@ -0,0 +1,30 @@
const { createServer } = require("http")
const { parse } = require("url")
const next = require("next")
const dev = process.env.NODE_ENV !== "production"
const hostname = "localhost"
const port = parseInt(process.env.PORT || "3000", 10)
const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true)
await handle(req, res, parsedUrl)
} catch (err) {
console.error("Error occurred handling", req.url, err)
res.statusCode = 500
res.end("internal server error")
}
})
.once("error", (err) => {
console.error(err)
process.exit(1)
})
.listen(port, () => {
console.log("> Ready on http://" + hostname + ":" + port)
})
})

53
src/app/agb/page.tsx Normal file
View file

@ -0,0 +1,53 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPage } from "@/lib/api"
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) : []
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>
</>
)
}

View file

@ -0,0 +1,51 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPage } from "@/lib/api"
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) : []
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>
</>
)
}

View file

@ -0,0 +1,63 @@
"use client"
import { useState } from "react"
interface FAQ {
question: string
answer: unknown
category?: string
}
interface FAQAccordionProps {
faqs: FAQ[]
}
function extractText(richText: unknown): string {
if (!richText || typeof richText !== "object") return ""
const root = (richText as { root?: { children?: unknown[] } }).root
if (!root?.children) return ""
function walk(nodes: unknown[]): string {
return nodes
.map((node) => {
const n = node as { type?: string; text?: string; children?: unknown[] }
if (n.type === "text" && n.text) return n.text
if (n.children) return walk(n.children)
return ""
})
.join("")
}
return walk(root.children)
}
export function FAQAccordion({ faqs }: FAQAccordionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null)
return (
<div className="space-y-3">
{faqs.map((faq, i) => (
<div key={i} className="border border-blush-border overflow-hidden">
<button
onClick={() => setOpenIndex(openIndex === i ? null : i)}
className="w-full flex items-center justify-between p-5 text-left hover:bg-blush-hover transition-colors"
>
<span className="font-cormorant text-[1.1rem] font-normal text-espresso pr-4">
{faq.question}
</span>
<span className={"font-playfair text-bordeaux text-xl transition-transform duration-300 shrink-0 " + (openIndex === i ? "rotate-45" : "")}>
+
</span>
</button>
<div
className={"overflow-hidden transition-all duration-300 " + (openIndex === i ? "max-h-96 opacity-100" : "max-h-0 opacity-0")}
>
<div className="px-5 pb-5 font-cormorant text-[1.05rem] font-light text-espresso/80 leading-[1.75]">
{extractText(faq.answer)}
</div>
</div>
</div>
))}
</div>
)
}

68
src/app/faq/page.tsx Normal file
View file

@ -0,0 +1,68 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchFAQs } from "@/lib/api"
import { FAQAccordion } from "./FAQAccordion"
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>)
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>
</>
)
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

77
src/app/galerie/page.tsx Normal file
View file

@ -0,0 +1,77 @@
import { ScrollReveal } from "@/components/ScrollReveal"
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 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>
</>
)
}

103
src/app/globals.css Normal file
View file

@ -0,0 +1,103 @@
@import "tailwindcss";
@theme inline {
--color-dark-wine: #2A1520;
--color-blush: #D4A9A0;
--color-bordeaux: #8B3A4A;
--color-navy: #151B2B;
--color-creme: #F8F4F0;
--color-espresso: #3D2F30;
--color-blush-soft: rgba(212, 169, 160, 0.15);
--color-blush-medium: rgba(212, 169, 160, 0.30);
--color-blush-border: rgba(212, 169, 160, 0.20);
--color-blush-hover: rgba(212, 169, 160, 0.10);
--font-playfair: "Playfair Display", serif;
--font-cormorant: "Cormorant Garamond", serif;
--font-josefin: "Josefin Sans", sans-serif;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-cormorant);
font-weight: 300;
line-height: 1.8;
color: var(--color-espresso);
background: var(--color-creme);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-playfair);
font-weight: 400;
line-height: 1.2;
}
/* Section label style (used across all sections) */
.section-label {
font-family: var(--font-josefin);
font-size: 0.6rem;
font-weight: 300;
text-transform: uppercase;
letter-spacing: 0.4em;
opacity: 0.5;
}
/* CTA button base styles */
.btn-cta {
font-family: var(--font-josefin);
font-size: 0.65rem;
font-weight: 300;
text-transform: uppercase;
letter-spacing: 0.25em;
padding: 14px 40px;
border: 1px solid var(--color-blush-border);
transition: all 0.4s ease;
cursor: pointer;
}
.btn-cta:hover {
border-color: var(--color-blush);
background: var(--color-blush-hover);
}
/* Submit button */
.btn-submit {
font-family: var(--font-josefin);
font-size: 0.62rem;
font-weight: 300;
text-transform: uppercase;
letter-spacing: 0.25em;
padding: 16px 48px;
background: var(--color-blush);
color: var(--color-dark-wine);
border: none;
cursor: pointer;
transition: all 0.4s ease;
}
.btn-submit:hover {
background: #E0B8B0;
box-shadow: 0 4px 20px rgba(212, 169, 160, 0.3);
transform: translateY(-2px);
}
/* Reveal animation */
.reveal {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease, transform 0.8s ease;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
/* Divider line */
.divider-line {
width: 48px;
height: 1px;
background: var(--color-blush);
}

View file

@ -0,0 +1,52 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPage } from "@/lib/api"
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) : []
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>
</>
)
}

View file

@ -0,0 +1,60 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchFromCMS } from "@/lib/api"
import { notFound } from "next/navigation"
interface PostDetail {
id: number
title: string
slug: string
content?: unknown
excerpt?: string
publishedAt?: string
coverImage?: { url?: string; alt?: string }
}
export default async function JournalDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const { docs } = await fetchFromCMS<PostDetail>({
collection: "posts",
where: { slug: { equals: slug }, status: { equals: "published" } },
depth: 2,
})
const post = docs[0]
if (!post) notFound()
return (
<>
{/* Hero */}
<section className="bg-dark-wine pt-40 pb-20 text-center">
{post.publishedAt && (
<p className="font-josefin text-[0.55rem] font-light uppercase tracking-[0.15em] text-blush/40 mb-4">
{new Date(post.publishedAt).toLocaleDateString("de-DE", { day: "numeric", month: "long", year: "numeric" })}
</p>
)}
<h1 className="font-playfair text-[clamp(2rem,4vw,3.5rem)] text-blush max-w-3xl mx-auto px-6">
{post.title}
</h1>
</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>
</ScrollReveal>
</div>
</section>
{/* Back link */}
<section className="bg-creme pb-20 text-center">
<a href="/journal" className="btn-cta inline-block text-espresso border-espresso/20 hover:border-bordeaux hover:text-bordeaux">
Zurueck zum Journal
</a>
</section>
</>
)
}

60
src/app/journal/page.tsx Normal file
View file

@ -0,0 +1,60 @@
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchPosts } from "@/lib/api"
export const metadata = {
title: "Journal | Sensual Moment Photography",
description: "Gedanken, Geschichten und Tipps rund um Boudoir-Fotografie und Selbstliebe.",
}
export default async function JournalPage() {
const posts = await fetchPosts(12)
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>
</>
)
}

24
src/app/kontakt/page.tsx Normal file
View file

@ -0,0 +1,24 @@
import { Contact } from "@/components/sections/Contact"
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>
{/* Contact section */}
<Contact />
</>
)
}

53
src/app/layout.tsx Normal file
View file

@ -0,0 +1,53 @@
import type { Metadata } from "next"
import { Playfair_Display, Cormorant_Garamond, Josefin_Sans } from "next/font/google"
import { Footer } from "@/components/Footer"
import { Navigation } from "@/components/Navigation"
import { fetchNavigation } from "@/lib/api"
import "./globals.css"
const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair",
display: "swap",
weight: ["400", "500", "600", "700"],
style: ["normal", "italic"],
})
const cormorant = Cormorant_Garamond({
subsets: ["latin"],
variable: "--font-cormorant",
display: "swap",
weight: ["300", "400", "500"],
style: ["normal", "italic"],
})
const josefin = Josefin_Sans({
subsets: ["latin"],
variable: "--font-josefin",
display: "swap",
weight: ["200", "300", "400"],
})
export const metadata: Metadata = {
title: "Sensual Moment Photography | Boudoir-Fotografie",
description: "Dein Moment der Selbstliebe. Professionelle Boudoir-Fotografie in einem geschützten, vertrauensvollen Rahmen.",
}
type NavigationDocument = {
mainMenu?: Array<Record<string, unknown>>
footerMenu?: Array<Record<string, unknown>>
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const navigation = (await fetchNavigation()) as NavigationDocument | null
return (
<html lang="de" className={playfair.variable + " " + cormorant.variable + " " + josefin.variable}>
<body className="bg-creme text-espresso antialiased">
<Navigation menuItems={navigation?.mainMenu} />
<main>{children}</main>
<Footer footerMenu={navigation?.footerMenu} />
</body>
</html>
)
}

24
src/app/page.tsx Normal file
View file

@ -0,0 +1,24 @@
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"
export default async function HomePage() {
const testimonials = await fetchTestimonials()
return (
<>
<Hero />
<AboutPreview />
<GalleryPreview />
<Testimonials testimonials={testimonials} />
<Packages />
<BlogPreview />
<Contact />
</>
)
}

63
src/app/pakete/page.tsx Normal file
View file

@ -0,0 +1,63 @@
import { Packages } from "@/components/sections/Packages"
import { ScrollReveal } from "@/components/ScrollReveal"
import { fetchFAQs } from "@/lib/api"
import { FAQAccordion } from "@/app/faq/FAQAccordion"
export const metadata = {
title: "Pakete & Preise | Sensual Moment Photography",
description: "Finde das passende Boudoir-Shooting-Paket fuer dich — von Entdecken bis Zelebrieren.",
}
export default async function PaketePage() {
const faqs = await fetchFAQs("kosten-buchung")
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>
</>
)
}

View file

@ -0,0 +1,93 @@
import { ScrollReveal } from "@/components/ScrollReveal"
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>
{/* 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>
</>
)
}

102
src/components/Footer.tsx Normal file
View file

@ -0,0 +1,102 @@
import Link from "next/link"
import { Logo } from "./Logo"
type FooterChild = {
id?: string | number
label?: string
title?: string
url?: string
href?: string
}
type FooterColumn = {
id?: string | number
label?: string
title?: string
items?: FooterChild[]
links?: FooterChild[]
children?: FooterChild[]
}
interface FooterProps {
footerMenu?: FooterColumn[]
}
function normalizeColumns(menu: FooterColumn[] = []) {
return menu
.map((column, index) => {
const heading = column.label || column.title || ""
const rawLinks = column.items || column.links || column.children || []
const links = rawLinks
.map((link, linkIndex) => ({
key: String(link.id || (link.url || link.href || "#") + "-" + linkIndex),
label: link.label || link.title || "",
href: link.url || link.href || "#",
}))
.filter((link) => link.label)
return {
key: String(column.id || heading + "-" + index),
heading,
links,
}
})
.filter((column) => column.heading || column.links.length > 0)
.slice(0, 3)
}
export function Footer({ footerMenu = [] }: FooterProps) {
const columns = normalizeColumns(footerMenu)
return (
<footer className="bg-navy text-creme">
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-12 px-6 py-16 md:grid-cols-2 lg:grid-cols-[1.3fr_1fr_1fr_1fr] lg:px-10">
<div className="space-y-5">
<Logo variant="navy" />
<p className="max-w-sm font-cormorant text-lg font-light leading-relaxed text-creme/80">
Luxurioese Boudoir-Fotografie fuer Frauen, die ihre Sinnlichkeit mit Stil, Selbstbewusstsein und Intimitat feiern wollen.
</p>
</div>
{columns.map((column) => (
<div key={column.key} className="space-y-4">
<h3 className="font-josefin text-[0.68rem] font-light uppercase tracking-[0.26em] text-blush">
{column.heading || "Links"}
</h3>
<ul className="space-y-2.5">
{column.links.map((link) => (
<li key={link.key}>
<Link
href={link.href}
className="font-cormorant text-lg font-light text-creme/80 transition-colors duration-300 hover:text-blush"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
<div className="border-t border-blush-border/70">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-5 md:flex-row md:items-center md:justify-between lg:px-10">
<div className="flex flex-wrap gap-5">
<Link href="/impressum" className="font-josefin text-[0.62rem] font-light uppercase tracking-[0.18em] text-creme/70 hover:text-blush">
Impressum
</Link>
<Link href="/datenschutz" className="font-josefin text-[0.62rem] font-light uppercase tracking-[0.18em] text-creme/70 hover:text-blush">
Datenschutz
</Link>
<Link href="/agb" className="font-josefin text-[0.62rem] font-light uppercase tracking-[0.18em] text-creme/70 hover:text-blush">
AGB
</Link>
</div>
<p className="font-josefin text-[0.6rem] font-light uppercase tracking-[0.16em] text-creme/60">
© {new Date().getFullYear()} Sensual Moment Photography
</p>
</div>
</div>
</footer>
)
}

22
src/components/Logo.tsx Normal file
View file

@ -0,0 +1,22 @@
import { cn } from "./utils"
interface LogoProps {
variant?: "primary" | "light" | "navy"
className?: string
}
const variantClasses: Record<NonNullable<LogoProps["variant"]>, string> = {
primary: "text-blush",
light: "text-bordeaux",
navy: "text-blush",
}
export function Logo({ variant = "primary", className }: LogoProps) {
return (
<div className={cn("inline-flex flex-col items-end leading-none", variantClasses[variant], className)}>
<span className="font-playfair text-[1.95rem] font-normal tracking-[0.03em]">Sensual</span>
<span className="-mt-1 pr-[0.06em] font-playfair text-[1.2rem] font-normal italic tracking-[0.02em]">Moment</span>
<span className="mt-1 font-josefin text-[0.52rem] font-light uppercase tracking-[0.42em]">PHOTOGRAPHY</span>
</div>
)
}

View file

@ -0,0 +1,117 @@
"use client"
import Link from "next/link"
import { useEffect, useState } from "react"
import { Logo } from "./Logo"
import { cn } from "./utils"
type MenuItem = {
id?: string | number
label?: string
title?: string
url?: string
href?: string
}
interface NavigationProps {
menuItems?: MenuItem[]
}
function normalizeMenu(items: MenuItem[] = []) {
return items
.map((item, index) => {
const label = item.label || item.title || ""
const href = item.url || item.href || "#"
return {
key: String(item.id || href + "-" + index),
label,
href,
}
})
.filter((item) => item.label)
}
export function Navigation({ menuItems = [] }: NavigationProps) {
const [isScrolled, setIsScrolled] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const links = normalizeMenu(menuItems)
useEffect(() => {
const onScroll = () => setIsScrolled(window.scrollY > 80)
onScroll()
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [])
useEffect(() => {
const onResize = () => {
if (window.innerWidth > 900) setIsOpen(false)
}
window.addEventListener("resize", onResize)
return () => window.removeEventListener("resize", onResize)
}, [])
return (
<header
className={cn(
"fixed inset-x-0 top-0 z-50 transition-all duration-500",
isScrolled ? "bg-dark-wine/95 backdrop-blur-md py-3" : "bg-transparent py-6",
)}
>
<div className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 lg:px-10">
<Link href="/" aria-label="Sensual Moment Photography">
<Logo variant="primary" className="max-[900px]:scale-95" />
</Link>
<nav className="hidden items-center gap-8 min-[901px]:flex" aria-label="Main navigation">
{links.map((item) => (
<Link
key={item.key}
href={item.href}
className="group relative font-josefin text-[0.72rem] font-light uppercase tracking-[0.18em] text-blush"
>
{item.label}
<span className="absolute -bottom-1 left-0 h-px w-0 bg-blush transition-all duration-300 group-hover:w-full" />
</Link>
))}
</nav>
<button
type="button"
aria-label={isOpen ? "Close navigation menu" : "Open navigation menu"}
aria-expanded={isOpen}
onClick={() => setIsOpen((prev) => !prev)}
className="hidden h-10 w-10 items-center justify-center text-blush max-[900px]:inline-flex"
>
<span className="relative block h-4 w-6">
<span className={cn("absolute left-0 top-0 h-px w-full bg-blush transition-all", isOpen && "top-1.5 rotate-45")} />
<span className={cn("absolute left-0 top-1.5 h-px w-full bg-blush transition-all", isOpen && "opacity-0")} />
<span className={cn("absolute left-0 top-3 h-px w-full bg-blush transition-all", isOpen && "top-1.5 -rotate-45")} />
</span>
</button>
</div>
<div
className={cn(
"overflow-hidden bg-dark-wine/95 px-6 transition-[max-height,opacity] duration-300 max-[900px]:block",
isOpen ? "max-h-80 py-4 opacity-100" : "max-h-0 py-0 opacity-0",
"min-[901px]:hidden",
)}
>
<nav className="flex flex-col gap-4" aria-label="Mobile navigation">
{links.map((item) => (
<Link
key={"mobile-" + item.key}
href={item.href}
onClick={() => setIsOpen(false)}
className="font-josefin text-[0.72rem] font-light uppercase tracking-[0.18em] text-blush"
>
{item.label}
</Link>
))}
</nav>
</div>
</header>
)
}

View file

@ -0,0 +1,46 @@
"use client"
import { type ReactNode, useEffect, useRef } from "react"
import { cn } from "./utils"
interface ScrollRevealProps {
children: ReactNode
className?: string
threshold?: number
rootMargin?: string
}
export function ScrollReveal({
children,
className,
threshold = 0.2,
rootMargin = "0px 0px -10% 0px",
}: ScrollRevealProps) {
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const node = ref.current
if (!node) return
const observer = new IntersectionObserver(
(entries, instance) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible")
instance.unobserve(entry.target)
}
})
},
{ threshold, rootMargin },
)
observer.observe(node)
return () => observer.disconnect()
}, [threshold, rootMargin])
return (
<div ref={ref} className={cn("reveal", className)}>
{children}
</div>
)
}

View file

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

View file

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

View file

@ -0,0 +1,159 @@
"use client"
import { useState } from "react"
import { ScrollReveal } from "@/components/ScrollReveal"
export function Contact() {
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
package: "",
message: "",
})
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
const CMS_URL = process.env.NEXT_PUBLIC_CMS_URL || "https://pl.porwoll.tech"
await fetch(CMS_URL + "/api/form-submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
form: 4, // sensualmoment contact form ID
submissionData: [
{ field: "name", value: formData.name },
{ field: "email", value: formData.email },
{ field: "phone", value: formData.phone },
{ field: "paket", value: formData.package },
{ field: "nachricht", value: formData.message },
],
}),
})
setSubmitted(true)
} catch {
alert("Es gab einen Fehler. Bitte versuche es erneut.")
} finally {
setSubmitting(false)
}
}
return (
<section className="bg-dark-wine 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">
{/* Left: Info */}
<ScrollReveal>
<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>?
</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
</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>
{/* Right: Form */}
<ScrollReveal>
{submitted ? (
<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!
</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.
</p>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-8">
<div>
<input
type="text"
placeholder="Dein Name"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-transparent border-0 border-b border-blush/20 focus:border-blush text-blush font-cormorant text-[1.05rem] font-light py-3 outline-none transition-colors placeholder:text-blush/30"
/>
</div>
<div>
<input
type="email"
placeholder="Deine E-Mail"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full bg-transparent border-0 border-b border-blush/20 focus:border-blush text-blush font-cormorant text-[1.05rem] font-light py-3 outline-none transition-colors placeholder:text-blush/30"
/>
</div>
<div>
<input
type="tel"
placeholder="Telefon (optional)"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full bg-transparent border-0 border-b border-blush/20 focus:border-blush text-blush font-cormorant text-[1.05rem] font-light py-3 outline-none transition-colors placeholder:text-blush/30"
/>
</div>
<div>
<select
value={formData.package}
onChange={(e) => setFormData({ ...formData, package: e.target.value })}
className="w-full bg-transparent border-0 border-b border-blush/20 focus:border-blush text-blush font-cormorant text-[1.05rem] font-light py-3 outline-none transition-colors [&>option]:bg-dark-wine [&>option]:text-blush"
>
<option value="">Paket-Interesse (optional)</option>
<option value="entdecken">Entdecken</option>
<option value="erleben">Erleben</option>
<option value="zelebrieren">Zelebrieren</option>
</select>
</div>
<div>
<textarea
placeholder="Deine Nachricht"
required
rows={4}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="w-full bg-transparent border-0 border-b border-blush/20 focus:border-blush text-blush font-cormorant text-[1.05rem] font-light py-3 outline-none transition-colors resize-none placeholder:text-blush/30"
/>
</div>
<button
type="submit"
disabled={submitting}
className="btn-submit disabled:opacity-50"
>
{submitting ? "Wird gesendet..." : "Nachricht senden"}
</button>
</form>
)}
</ScrollReveal>
</div>
</div>
</section>
)
}

View file

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

View file

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

View file

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

View file

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

3
src/components/utils.ts Normal file
View file

@ -0,0 +1,3 @@
export function cn(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ")
}

115
src/lib/api.ts Normal file
View file

@ -0,0 +1,115 @@
const CMS_URL = process.env.NEXT_PUBLIC_CMS_URL || "https://pl.porwoll.tech"
const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID || "13"
interface FetchOptions {
collection: string
where?: Record<string, unknown>
limit?: number
page?: number
sort?: string
depth?: number
locale?: string
}
export async function fetchFromCMS<T = unknown>({
collection,
where = {},
limit = 100,
page = 1,
sort,
depth = 1,
locale = "de",
}: FetchOptions): Promise<{ docs: T[]; totalDocs: number; totalPages: number }> {
const params = new URLSearchParams()
params.set("where[tenant][equals]", TENANT_ID)
params.set("limit", String(limit))
params.set("page", String(page))
params.set("depth", String(depth))
params.set("locale", locale)
if (sort) params.set("sort", sort)
for (const [key, value] of Object.entries(where)) {
if (typeof value === "object" && value !== null) {
for (const [op, val] of Object.entries(value as Record<string, unknown>)) {
params.set("where[" + key + "][" + op + "]", String(val))
}
} else {
params.set("where[" + key + "][equals]", String(value))
}
}
const url = CMS_URL + "/api/" + collection + "?" + params.toString()
const res = await fetch(url, { next: { revalidate: 60 } })
if (!res.ok) {
console.error("CMS fetch failed:", url, res.status)
return { docs: [], totalDocs: 0, totalPages: 0 }
}
return res.json()
}
export async function fetchPage(slug: string) {
const { docs } = await fetchFromCMS<{ id: number; title: string; layout: unknown[]; slug: string }>({
collection: "pages",
where: { slug: { equals: slug } },
depth: 2,
})
return docs[0] || null
}
export async function fetchNavigation() {
const { docs } = await fetchFromCMS<{ mainMenu: unknown[]; footerMenu: unknown[] }>({
collection: "navigations",
depth: 2,
})
return docs[0] || null
}
export async function fetchSiteSettings() {
const { docs } = await fetchFromCMS<Record<string, unknown>>({
collection: "site-settings",
depth: 1,
})
return docs[0] || null
}
export async function fetchTestimonials() {
const { docs } = await fetchFromCMS<{ quote: string; author: string; role?: string }>({
collection: "testimonials",
where: { isActive: { equals: true } },
sort: "order",
})
return docs
}
export async function fetchFAQs(category?: string) {
const where: Record<string, unknown> = {}
if (category) where.category = { equals: category }
const { docs } = await fetchFromCMS<{ question: string; answer: unknown; category: string }>({
collection: "faqs",
where,
sort: "order",
limit: 50,
})
return docs
}
export async function fetchSocialLinks() {
const { docs } = await fetchFromCMS<{ platform: string; url: string; label?: string }>({
collection: "social-links",
sort: "order",
})
return docs
}
export async function fetchPosts(limit = 3) {
const { docs } = await fetchFromCMS<{ title: string; slug: string; excerpt?: string; coverImage?: unknown; publishedAt?: string }>({
collection: "posts",
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit,
depth: 2,
})
return docs
}

34
tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}