feat: apply Definity Template design system

- Tailwind v4 theme config with Definity colors (accent #2CAADF, dark #111)
- Montserrat + Open Sans font setup via next/font/google
- UI components: Button (6 variants), Container, SectionHeader
- Navigation with transparent-to-solid scroll behavior
- Footer with social links, widgets, copyright bar
- Block components: Hero, Text, CardGrid, Quote, ImageText, CTA,
  ContactForm, Timeline, Divider
- Typography system with prose styles for rich text
- Animation library with framer-motion variants
- Page transitions via template.tsx
- cn() utility (clsx + tailwind-merge)
- Updated API layer with getSocialLinks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-15 11:06:05 +00:00
parent e998553440
commit 2e8d34d917
26 changed files with 1795 additions and 98 deletions

View file

@ -10,9 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@c2s/payload-contracts": "github:complexcaresolutions/payload-contracts", "@c2s/payload-contracts": "github:complexcaresolutions/payload-contracts",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
"next": "16.0.10", "next": "16.0.10",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1" "react-dom": "19.2.1",
"react-icons": "^5.5.0",
"tailwind-merge": "^3.4.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View file

@ -11,6 +11,12 @@ importers:
'@c2s/payload-contracts': '@c2s/payload-contracts':
specifier: github:complexcaresolutions/payload-contracts specifier: github:complexcaresolutions/payload-contracts
version: git+https://git@github.com:complexcaresolutions/payload-contracts.git#64847594b2150bfdce09a7bd7f54ad2f52d6f2b7(react@19.2.1) version: git+https://git@github.com:complexcaresolutions/payload-contracts.git#64847594b2150bfdce09a7bd7f54ad2f52d6f2b7(react@19.2.1)
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^12.34.0
version: 12.34.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
next: next:
specifier: 16.0.10 specifier: 16.0.10
version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@ -20,6 +26,12 @@ importers:
react-dom: react-dom:
specifier: 19.2.1 specifier: 19.2.1
version: 19.2.1(react@19.2.1) version: 19.2.1(react@19.2.1)
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.2.1)
tailwind-merge:
specifier: ^3.4.1
version: 3.4.1
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
@ -811,6 +823,10 @@ packages:
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@ -1100,6 +1116,20 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
framer-motion@12.34.0:
resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@ -1487,6 +1517,12 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
motion-dom@12.34.0:
resolution: {integrity: sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==}
motion-utils@12.29.2:
resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -1632,6 +1668,11 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.1 react: ^19.2.1
react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies:
react: '*'
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -1797,6 +1838,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
tailwind-merge@3.4.1:
resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==}
tailwindcss@4.1.18: tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
@ -2665,6 +2709,8 @@ snapshots:
client-only@0.0.1: {} client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@ -3100,6 +3146,15 @@ snapshots:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
framer-motion@12.34.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
motion-dom: 12.34.0
motion-utils: 12.29.2
tslib: 2.8.1
optionalDependencies:
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
function-bind@1.1.2: {} function-bind@1.1.2: {}
function.prototype.name@1.1.8: function.prototype.name@1.1.8:
@ -3466,6 +3521,12 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
motion-dom@12.34.0:
dependencies:
motion-utils: 12.29.2
motion-utils@12.29.2: {}
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
@ -3611,6 +3672,10 @@ snapshots:
react: 19.2.1 react: 19.2.1
scheduler: 0.27.0 scheduler: 0.27.0
react-icons@5.5.0(react@19.2.1):
dependencies:
react: 19.2.1
react-is@16.13.1: {} react-is@16.13.1: {}
react@19.2.1: {} react@19.2.1: {}
@ -3846,6 +3911,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
tailwind-merge@3.4.1: {}
tailwindcss@4.1.18: {} tailwindcss@4.1.18: {}
tapable@2.3.0: {} tapable@2.3.0: {}

View file

@ -3,23 +3,29 @@ import { BlockRenderer } from "@/components/blocks"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import type { Metadata } from "next" import type { Metadata } from "next"
interface Props { interface PageProps {
params: Promise<{ slug: string }> params: Promise<{ slug: string }>
} }
export async function generateStaticParams() { export async function generateStaticParams() {
const data = await getPages({ limit: 100 }) try {
return data.docs const pages = await getPages({ limit: 100 })
.filter((p) => p.slug !== "home") return (pages?.docs || [])
.filter((p) => p.slug && p.slug !== "home")
.map((p) => ({ slug: p.slug })) .map((p) => ({ slug: p.slug }))
} catch {
return []
}
} }
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params const { slug } = await params
const [page, seoSettings] = await Promise.all([getPage(slug), getSeoSettings()]) const [page, seoSettings] = await Promise.all([getPage(slug), getSeoSettings()])
if (!page) return {} if (!page) return {}
const titleSuffix = (seoSettings as any)?.metaDefaults?.titleSuffix || "" const titleSuffix = (seoSettings as Record<string, unknown>)?.metaDefaults
? ((seoSettings as Record<string, unknown>).metaDefaults as Record<string, unknown>)?.titleSuffix as string || ""
: ""
const title = page.seo?.metaTitle || page.title const title = page.seo?.metaTitle || page.title
const description = page.seo?.metaDescription || "" const description = page.seo?.metaDescription || ""
@ -29,17 +35,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
} }
} }
export default async function DynamicPage({ params }: Props) { export default async function DynamicPage({ params }: PageProps) {
const { slug } = await params const { slug } = await params
const page = await getPage(slug) const page = await getPage(slug)
if (!page) { if (!page) notFound()
notFound()
}
return ( return <BlockRenderer blocks={page.layout || []} />
<main>
<BlockRenderer blocks={page.layout} />
</main>
)
} }

View file

@ -1,26 +1,127 @@
@import "tailwindcss"; @import 'tailwindcss';
:root { /* Tailwind v4 Theme Configuration */
--background: #ffffff; @theme {
--foreground: #171717; /* Colors */
--color-dark: #111111;
--color-gray: #777777;
--color-gray-light: #999999;
--color-light: #ececec;
--color-light-soft: #f8f8f8;
--color-light-bg: #f4f4f4;
--color-accent: #2CAADF;
--color-accent-dark: #1a8fc4;
--color-error: #e80000;
--color-success: #0F9D58;
--color-footer-bg: #e3e3e3;
--color-footer-copyright: #222222;
--color-border: #dddddd;
--color-border-light: #cccccc;
/* Fonts */
--font-heading: var(--font-montserrat), 'Montserrat', 'Open Sans', 'Helvetica Neue', sans-serif;
--font-heading-alt: var(--font-open-sans), 'Open Sans', 'Montserrat', 'Helvetica Neue', sans-serif;
--font-body: var(--font-open-sans), 'Open Sans', 'Helvetica Neue', 'Helvetica', sans-serif;
/* Spacing */
--spacing-ws-s: 50px;
--spacing-ws-m: 100px;
--spacing-ws-l: 160px;
/* Shadows */
--shadow-card: 0 1px 1px rgba(0, 0, 0, 0.2);
--shadow-card-hover: 0 22px 43px rgba(0, 0, 0, 0.15);
} }
@theme inline { /* Base Styles */
--color-background: var(--background); @layer base {
--color-foreground: var(--foreground); html {
--font-sans: var(--font-geist-sans); font-size: 16px;
--font-mono: var(--font-geist-mono); scroll-behavior: smooth;
} }
@media (prefers-color-scheme: dark) { body {
:root { font-family: var(--font-body);
--background: #0a0a0a; font-weight: 300;
--foreground: #ededed; font-size: 14px;
letter-spacing: 0.2px;
line-height: 1.8em;
color: var(--color-gray);
background-color: white;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 700;
text-transform: uppercase;
color: var(--color-dark);
}
h1 { font-size: 1.7em; letter-spacing: 5px; }
h2 { font-size: 1.5em; letter-spacing: 4px; }
h3 { font-size: 1.3em; letter-spacing: 3.5px; }
h4 { font-size: 1.07em; letter-spacing: 3px; }
h5 { font-size: 1em; letter-spacing: 2px; }
h6 { font-size: 0.85em; letter-spacing: 2px; }
/* Links */
a {
color: var(--color-accent);
border-bottom: 1px solid transparent;
transition: all 500ms;
text-decoration: none;
}
a:hover {
border-bottom-color: var(--color-accent);
}
/* Selection */
::selection {
background: var(--color-dark);
color: white;
} }
} }
body { /* Component Layer */
background: var(--background); @layer components {
color: var(--foreground); .container-def {
font-family: Arial, Helvetica, sans-serif; max-width: 1200px;
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
.section-spacing {
padding-top: var(--spacing-ws-m);
padding-bottom: var(--spacing-ws-m);
}
.section-spacing-lg {
padding-top: var(--spacing-ws-l);
padding-bottom: var(--spacing-ws-l);
}
.h-alt {
font-family: var(--font-heading-alt);
font-weight: 300;
text-transform: uppercase;
}
}
/* Utility Layer */
@layer utilities {
.bg-parallax {
background-attachment: fixed;
background-size: cover;
background-position: center;
}
.transition-def {
transition: all 500ms;
}
} }

View file

@ -1,32 +1,84 @@
import type { Metadata } from "next" import type { Metadata } from 'next'
import { Inter } from "next/font/google" import { Montserrat, Open_Sans } from 'next/font/google'
import "./globals.css" import { Navigation } from '@/components/Navigation'
import { getSiteSettings } from "@/lib/api" import { Footer } from '@/components/Footer'
import { getNavigation, getSiteSettings, getSocialLinks } from '@/lib/api'
import './globals.css'
const inter = Inter({ const montserrat = Montserrat({
variable: "--font-inter", subsets: ['latin'],
subsets: ["latin"], weight: ['400', '700'],
variable: '--font-montserrat',
display: 'swap',
}) })
export async function generateMetadata(): Promise<Metadata> { const openSans = Open_Sans({
const settings = await getSiteSettings() subsets: ['latin'],
return { weight: ['300', '400', '600', '700', '800'],
title: { variable: '--font-open-sans',
default: settings?.siteName || "Caroline Porwoll", display: 'swap',
template: `%s | ${settings?.siteName || "Caroline Porwoll"}`, })
},
description: "Professionelle Portrait- und Businessfotografie", export const metadata: Metadata = {
} title: 'Martin Porwoll',
description: 'Whistleblower. Unternehmer. Mensch.',
} }
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: {
children: React.ReactNode children: React.ReactNode
}>) { }) {
const [navigation, siteSettings, socialLinks] = await Promise.all([
getNavigation('header'),
getSiteSettings(),
getSocialLinks(),
])
// Transform CMS navigation items into NavItem format
const nav = navigation as unknown as Record<string, unknown> | null
const navItems = nav?.items
? (nav.items as Array<Record<string, unknown>>).map(
(item) => ({
label: (item.label as string) || '',
href: (item.link as string) || (item.url as string) || '#',
})
)
: [
{ label: 'Home', href: '/' },
{ label: 'Der Mensch', href: '/mensch' },
{ label: 'Whistleblowing', href: '/whistleblowing' },
{ label: 'Kontakt', href: '/kontakt' },
]
const settings = siteSettings as unknown as Record<string, unknown> | null
const logoMedia = settings?.logo as Record<string, unknown> | undefined
const logoUrl = logoMedia?.url as string | undefined
const contactInfo = settings?.contactInfo as Record<string, string> | undefined
return ( return (
<html lang="de"> <html lang="de" className={`${montserrat.variable} ${openSans.variable}`}>
<body className={`${inter.variable} antialiased`}>{children}</body> <body className="font-body">
<Navigation
items={navItems}
logo={logoUrl}
transparent={true}
/>
<main id="top">
{children}
</main>
<Footer
socialLinks={socialLinks}
contact={{
email: contactInfo?.email,
phone: contactInfo?.phone,
address: contactInfo?.address,
}}
/>
</body>
</html> </html>
) )
} }

View file

@ -1,13 +1,13 @@
import { getPage, getSeoSettings } from "@/lib/api" import { getPage, getSeoSettings } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks" import { BlockRenderer } from "@/components/blocks"
import { notFound } from "next/navigation"
import type { Metadata } from "next" import type { Metadata } from "next"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const [page, seoSettings] = await Promise.all([getPage("home"), getSeoSettings()]) const [page, seoSettings] = await Promise.all([getPage("home"), getSeoSettings()])
if (!page) return {} if (!page) return { title: 'Martin Porwoll' }
const titleSuffix = (seoSettings as any)?.metaDefaults?.titleSuffix || "" const seo = seoSettings as unknown as Record<string, unknown> | null
const titleSuffix = (seo?.metaDefaults as Record<string, unknown>)?.titleSuffix as string || ""
const title = page.seo?.metaTitle || page.title const title = page.seo?.metaTitle || page.title
const description = page.seo?.metaDescription || "" const description = page.seo?.metaDescription || ""
@ -22,18 +22,24 @@ export default async function HomePage() {
if (!page) { if (!page) {
return ( return (
<main className="flex min-h-screen items-center justify-center"> <section
<div className="text-center"> className="relative h-screen w-full bg-cover bg-center bg-fixed"
<h1 className="text-4xl font-bold mb-4">Caroline Porwoll</h1> style={{ backgroundColor: '#111' }}
<p className="text-lg text-gray-600">Website wird vorbereitet...</p> >
<div className="absolute inset-0 bg-dark/60" />
<div className="table absolute inset-0 w-full h-full">
<div className="table-cell w-full h-full align-middle text-center px-4">
<h1 className="font-heading font-bold text-[3em] md:text-[5em] tracking-[10px] md:tracking-[15px] text-white mb-[50px]">
MARTIN PORWOLL
</h1>
<p className="text-[1.2em] tracking-[2px] text-gray-light">
Whistleblower. Unternehmer. Mensch.
</p>
</div> </div>
</main> </div>
</section>
) )
} }
return ( return <BlockRenderer blocks={page.layout || []} />
<main>
<BlockRenderer blocks={page.layout} />
</main>
)
} }

15
src/app/template.tsx Normal file
View file

@ -0,0 +1,15 @@
'use client'
import { motion } from 'framer-motion'
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
{children}
</motion.div>
)
}

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

@ -0,0 +1,196 @@
import Link from 'next/link'
import { Container } from './ui/Container'
import {
FaFacebook,
FaInstagram,
FaLinkedin,
FaYoutube,
FaXTwitter,
} from 'react-icons/fa6'
import { HiOutlineMail, HiOutlinePhone, HiOutlineLocationMarker } from 'react-icons/hi'
interface FooterProps {
socialLinks?: {
platform: string
url: string
}[]
contact?: {
email?: string
phone?: string
address?: string
}
}
const socialIcons: Record<string, React.ComponentType<{ className?: string }>> = {
facebook: FaFacebook,
instagram: FaInstagram,
linkedin: FaLinkedin,
youtube: FaYoutube,
x: FaXTwitter,
twitter: FaXTwitter,
}
export function Footer({ socialLinks = [], contact }: FooterProps) {
return (
<footer>
{/* Social Links Bar */}
{socialLinks.length > 0 && (
<div className="text-center border-t border-light">
<ul className="list-none m-0 p-0 py-[65px]">
{socialLinks.map((social, index) => (
<li
key={social.platform}
className={`
inline-block pr-[35px] mr-[35px]
font-heading text-[0.85em] tracking-[2px] uppercase
${index < socialLinks.length - 1 ? 'border-r border-gray' : ''}
`}
>
<a
href={social.url}
target="_blank"
rel="noopener noreferrer"
className="text-dark hover:text-gray-light border-none transition-colors duration-500"
>
{social.platform}
</a>
</li>
))}
</ul>
</div>
)}
{/* Footer Widgets */}
<div className="bg-dark py-ws-m">
<Container>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
{/* About */}
<div>
<h4 className="pb-[17px] mb-[25px] border-b border-white/20 font-heading text-[0.85em] tracking-[2px] uppercase text-white">
About
</h4>
<p className="text-[0.9em] text-gray m-0">
Martin Porwoll - Whistleblower im Zytoskandal Bottrop, Unternehmer und Familienvater.
</p>
{/* Social Icons */}
<ul className="list-none p-0 m-0 mt-[30px]">
{socialLinks.slice(0, 5).map((social) => {
const Icon = socialIcons[social.platform.toLowerCase()]
return Icon ? (
<li key={social.platform} className="inline-block pr-[15px] text-[22px]">
<a
href={social.url}
target="_blank"
rel="noopener noreferrer"
className="text-gray hover:text-white border-none transition-colors duration-500"
>
<Icon />
</a>
</li>
) : null
})}
</ul>
</div>
{/* Navigation */}
<div>
<h4 className="pb-[17px] mb-[25px] border-b border-white/20 font-heading text-[0.85em] tracking-[2px] uppercase text-white">
Navigation
</h4>
<ul className="list-none p-0 m-0 space-y-2">
{[
{ label: 'Home', href: '/' },
{ label: 'Der Mensch', href: '/mensch' },
{ label: 'Whistleblowing', href: '/whistleblowing' },
{ label: 'Kontakt', href: '/kontakt' },
].map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-[0.9em] text-gray hover:text-white border-none transition-colors duration-500"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Rechtliches */}
<div>
<h4 className="pb-[17px] mb-[25px] border-b border-white/20 font-heading text-[0.85em] tracking-[2px] uppercase text-white">
Rechtliches
</h4>
<ul className="list-none p-0 m-0 space-y-2">
<li>
<Link
href="/impressum"
className="text-[0.9em] text-gray hover:text-white border-none transition-colors duration-500"
>
Impressum
</Link>
</li>
<li>
<Link
href="/datenschutz"
className="text-[0.9em] text-gray hover:text-white border-none transition-colors duration-500"
>
Datenschutz
</Link>
</li>
</ul>
</div>
{/* Kontakt */}
<div>
<h4 className="pb-[17px] mb-[25px] border-b border-white/20 font-heading text-[0.85em] tracking-[2px] uppercase text-white">
Kontakt
</h4>
<ul className="list-none p-0 m-0 space-y-3">
{contact?.email && (
<li className="flex items-center gap-3 text-[0.9em] text-gray">
<HiOutlineMail className="text-[18px]" />
<a href={`mailto:${contact.email}`} className="hover:text-white border-none">
{contact.email}
</a>
</li>
)}
{contact?.phone && (
<li className="flex items-center gap-3 text-[0.9em] text-gray">
<HiOutlinePhone className="text-[18px]" />
<a href={`tel:${contact.phone}`} className="hover:text-white border-none">
{contact.phone}
</a>
</li>
)}
{contact?.address && (
<li className="flex items-start gap-3 text-[0.9em] text-gray">
<HiOutlineLocationMarker className="text-[18px] mt-0.5" />
<span>{contact.address}</span>
</li>
)}
</ul>
</div>
</div>
</Container>
</div>
{/* Copyright Bar */}
<div className="bg-[#222] py-5">
<Container>
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<p className="font-heading text-[0.8em] tracking-[1.3px] uppercase text-gray m-0">
&copy; {new Date().getFullYear()} Martin Porwoll. Alle Rechte vorbehalten.
</p>
<a
href="#top"
className="font-heading text-[0.8em] tracking-[1.3px] uppercase text-gray hover:text-white border-none transition-colors duration-500"
>
Nach oben
</a>
</div>
</Container>
</div>
</footer>
)
}

View file

@ -0,0 +1,178 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { usePathname } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { cn } from '@/lib/utils'
interface NavItem {
label: string
href: string
children?: NavItem[]
}
interface NavigationProps {
items: NavItem[]
logo?: string
transparent?: boolean
}
export function Navigation({ items, logo, transparent = false }: NavigationProps) {
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
const pathname = usePathname()
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 100)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const isTransparent = transparent && !scrolled
return (
<nav
className={cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
isTransparent
? 'bg-transparent border-b-0'
: 'bg-white border-b border-light',
scrolled ? 'min-h-[50px]' : 'min-h-[75px]'
)}
>
<div className="max-w-[1200px] mx-auto px-4 flex items-center justify-between h-full">
{/* Logo */}
<Link href="/" className="py-[15px] border-none">
{logo ? (
<Image
src={logo}
alt="Logo"
width={150}
height={40}
className={cn(
'h-auto transition-all duration-300',
scrolled ? 'max-h-[30px]' : 'max-h-[40px]'
)}
/>
) : (
<span
className={cn(
'font-heading font-bold text-[1.2em] tracking-[3px] uppercase',
isTransparent ? 'text-white' : 'text-dark'
)}
>
PORWOLL
</span>
)}
</Link>
{/* Desktop Navigation */}
<div className="hidden lg:flex items-center gap-8">
{items.map((item) => (
<div
key={item.href}
className="relative"
onMouseEnter={() => item.children && setActiveDropdown(item.href)}
onMouseLeave={() => setActiveDropdown(null)}
>
<Link
href={item.href}
className={cn(
'font-heading uppercase text-[0.8em] tracking-[2px] transition-colors duration-500 border-none',
isTransparent
? 'text-gray hover:text-white'
: 'text-gray-light hover:text-dark',
pathname === item.href && (isTransparent ? 'text-white' : 'text-dark')
)}
>
{item.label}
</Link>
{/* Dropdown */}
<AnimatePresence>
{item.children && activeDropdown === item.href && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className="absolute top-full left-0 mt-3 py-5 pb-2.5 min-w-[200px] bg-white/95 border border-light"
>
{item.children.map((child) => (
<Link
key={child.href}
href={child.href}
className={cn(
'block px-[25px] py-[11px] pr-[40px]',
'font-body text-[0.9em] tracking-[0.5px] leading-[0.8em]',
'border-l-[3px] border-transparent',
'transition-all duration-300',
'hover:pl-[35px] hover:pr-[30px] hover:bg-dark/5 hover:border-l-dark',
'text-gray hover:text-dark border-b-0'
)}
>
{child.label}
</Link>
))}
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
{/* Mobile Menu Button */}
<button
className="lg:hidden p-2"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
>
<div className={cn('w-6 h-0.5 mb-1.5 transition-all', isTransparent ? 'bg-white' : 'bg-dark')} />
<div className={cn('w-6 h-0.5 mb-1.5 transition-all', isTransparent ? 'bg-white' : 'bg-dark')} />
<div className={cn('w-6 h-0.5 transition-all', isTransparent ? 'bg-white' : 'bg-dark')} />
</button>
</div>
{/* Mobile Menu */}
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="lg:hidden bg-white border-t border-light"
>
<div className="py-4">
{items.map((item) => (
<div key={item.href}>
<Link
href={item.href}
className="block px-4 py-3 font-heading uppercase text-[0.85em] tracking-[2px] text-gray-light hover:text-dark border-none"
onClick={() => setMobileOpen(false)}
>
{item.label}
</Link>
{item.children?.map((child) => (
<Link
key={child.href}
href={child.href}
className="block px-8 py-2 text-[0.85em] text-gray hover:text-dark border-none"
onClick={() => setMobileOpen(false)}
>
{child.label}
</Link>
))}
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</nav>
)
}

View file

@ -0,0 +1,51 @@
'use client'
import { motion } from 'framer-motion'
import { Button } from '../ui/Button'
interface CTABlockProps {
block: Record<string, unknown>
}
export function CTABlock({ block }: CTABlockProps) {
const title = (block.headline as string) || (block.title as string) || ''
const subtitle = (block.subheadline as string) || (block.subtitle as string) || ''
const backgroundMedia = block.backgroundImage as Record<string, unknown> | undefined
const backgroundUrl = backgroundMedia?.url as string | undefined
const ctaLabel = (block.ctaLabel as string) || (block.buttonText as string) || 'Mehr erfahren'
const ctaLink = (block.ctaLink as string) || (block.buttonLink as string) || '#'
return (
<section
className="relative h-[365px] bg-cover bg-center"
style={{
backgroundImage: backgroundUrl ? `url(${backgroundUrl})` : undefined,
backgroundColor: !backgroundUrl ? '#111' : undefined,
}}
>
{/* Overlay */}
<div className="absolute inset-0 bg-dark/50" />
{/* Content */}
<motion.div
className="relative pt-[100px] w-full h-full text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<h2 className="font-heading font-bold text-[1.5em] tracking-[8px] uppercase text-white mb-4">
{title}
</h2>
{subtitle && (
<p className="text-white/80 mb-8">{subtitle}</p>
)}
<Button href={ctaLink} variant="ghost-light" size="large">
{ctaLabel}
</Button>
</motion.div>
</section>
)
}

View file

@ -0,0 +1,115 @@
'use client'
import { motion } from 'framer-motion'
import Link from 'next/link'
import Image from 'next/image'
import { Container } from '../ui/Container'
import { SectionHeader } from '../ui/SectionHeader'
import { cn } from '@/lib/utils'
interface CardGridBlockProps {
block: Record<string, unknown>
}
export function CardGridBlock({ block }: CardGridBlockProps) {
const title = (block.title as string) || (block.headline as string) || ''
const subtitle = (block.subtitle as string) || ''
const cards = (block.cards as Array<Record<string, unknown>>) || []
const columns = (block.columns as number) || 3
const backgroundColor = (block.backgroundColor as string) || 'white'
const gridCols: Record<number, string> = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-2 lg:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-4',
}
return (
<section
className={cn(
'py-ws-m',
backgroundColor === 'light' && 'bg-light-bg'
)}
>
<Container>
{(title || subtitle) && (
<SectionHeader title={title} subtitle={subtitle} />
)}
<div className={cn('grid grid-cols-1 gap-8', gridCols[columns] || gridCols[3])}>
{cards.map((card, index) => {
const cardTitle = (card.title as string) || ''
const cardDescription = (card.description as string) || (card.text as string) || ''
const cardIcon = card.icon as string | undefined
const cardImage = card.image as Record<string, unknown> | undefined
const cardImageUrl = cardImage?.url as string | undefined
const cardLink = card.link as Record<string, unknown> | undefined
const cardLinkLabel = (cardLink?.label as string) || 'Mehr erfahren'
const cardLinkHref = (cardLink?.href as string) || (cardLink?.url as string) || ''
return (
<motion.div
key={index}
className={cn(
'p-[50px_40px]',
'border border-light-soft bg-white',
'shadow-card transition-all duration-300',
'hover:-translate-y-[10px] hover:shadow-card-hover'
)}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1, duration: 0.5 }}
>
{/* Icon */}
{cardIcon && (
<div className="text-[64px] text-dark mb-6">
<span>{cardIcon}</span>
</div>
)}
{/* Image */}
{cardImageUrl && (
<div className="mb-6 -mx-[40px] -mt-[50px]">
<Image
src={cardImageUrl}
alt={cardTitle}
width={400}
height={250}
className="w-full h-auto"
/>
</div>
)}
{/* Title */}
<h3 className="font-heading font-bold text-[1.07em] tracking-[3px] uppercase text-dark mt-0 mb-[30px]">
{cardTitle}
</h3>
{/* Description */}
<p className="text-gray leading-[1.8em] m-0">
{cardDescription}
</p>
{/* Link */}
{cardLinkHref && (
<Link
href={cardLinkHref}
className={cn(
'block pt-[50px] text-right',
'font-heading text-[0.85em] tracking-[2px] uppercase',
'text-gray-light hover:text-dark',
'border-none transition-colors duration-500'
)}
>
{cardLinkLabel} &rarr;
</Link>
)}
</motion.div>
)
})}
</div>
</Container>
</section>
)
}

View file

@ -0,0 +1,152 @@
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Container } from '../ui/Container'
import { Button } from '../ui/Button'
import { cn } from '@/lib/utils'
interface ContactFormBlockProps {
block: Record<string, unknown>
}
export function ContactFormBlock({ block }: ContactFormBlockProps) {
const title = (block.title as string) || (block.headline as string) || 'Kontakt'
const subtitle = (block.subtitle as string) || 'Haben Sie Fragen? Schreiben Sie mir.'
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setStatus('sending')
try {
const formData = new FormData(e.currentTarget)
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.get('name'),
email: formData.get('email'),
subject: formData.get('subject'),
message: formData.get('message'),
}),
})
if (response.ok) {
setStatus('success')
} else {
setStatus('error')
}
} catch {
setStatus('error')
}
}
const inputClasses = cn(
'w-full h-[40px] px-0',
'border-0 border-b border-gray-light',
'bg-transparent',
'font-body text-[14px] tracking-[0.2px] text-dark',
'transition-colors duration-300',
'focus:outline-none focus:border-dark',
'placeholder:text-gray-light'
)
return (
<section className="bg-light-bg">
<Container>
<motion.div
className={cn(
'my-ws-l mx-auto max-w-3xl',
'py-ws-l px-8 md:px-[100px] pb-ws-m',
'border-t border-light-soft',
'bg-white',
'shadow-card'
)}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
{/* Header */}
<header className="text-center mb-ws-m">
<h2 className="font-heading font-bold text-[1.5em] tracking-[4px] uppercase text-dark mt-0 mb-[30px]">
{title}
</h2>
<p className="text-gray-light">{subtitle}</p>
</header>
{/* Success Message */}
{status === 'success' && (
<div className="bg-success/10 border border-success text-success p-4 mb-8 text-center">
Vielen Dank! Ich werde mich schnellstmöglich bei Ihnen melden.
</div>
)}
{/* Error Message */}
{status === 'error' && (
<div className="bg-error/10 border border-error text-error p-4 mb-8 text-center">
Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<input
type="text"
name="name"
placeholder="Name"
required
className={inputClasses}
/>
</div>
<div>
<input
type="email"
name="email"
placeholder="E-Mail"
required
className={inputClasses}
/>
</div>
</div>
<div>
<input
type="text"
name="subject"
placeholder="Betreff"
required
className={inputClasses}
/>
</div>
<div>
<textarea
name="message"
placeholder="Nachricht"
required
rows={5}
className={cn(
inputClasses,
'h-auto min-h-[120px] resize-none py-2'
)}
/>
</div>
<div className="text-center pt-4">
<Button
type="submit"
disabled={status === 'sending'}
>
{status === 'sending' ? 'Wird gesendet...' : 'Nachricht senden'}
</Button>
</div>
</form>
</motion.div>
</Container>
</section>
)
}

View file

@ -0,0 +1,20 @@
import { cn } from '@/lib/utils'
interface DividerBlockProps {
block: Record<string, unknown>
}
export function DividerBlock({ block }: DividerBlockProps) {
const style = (block.style as string) || 'line'
return (
<div className="py-ws-s">
<div
className={cn(
'max-w-[1200px] mx-auto px-4',
style === 'space' ? 'border-none' : 'border-t border-light'
)}
/>
</div>
)
}

View file

@ -0,0 +1,118 @@
'use client'
import { motion } from 'framer-motion'
import { Button } from '../ui/Button'
import { cn } from '@/lib/utils'
interface HeroBlockProps {
block: Record<string, unknown>
}
export function HeroBlock({ block }: HeroBlockProps) {
const headline = (block.headline as string) || (block.title as string) || ''
const subheadline = (block.subheadline as string) || (block.subtitle as string) || ''
const backgroundMedia = block.backgroundImage as Record<string, unknown> | undefined
const backgroundUrl = backgroundMedia?.url as string | undefined
const ctaLabel = (block.ctaLabel as string) || (block.buttonText as string) || ''
const ctaLink = (block.ctaLink as string) || (block.buttonLink as string) || ''
const overlayStyle = (block.overlayStyle as string) || 'dark'
const alignment = (block.alignment as string) || 'center'
const fullHeight = block.fullHeight !== false
const isDark = overlayStyle === 'dark'
return (
<section
className={cn(
'relative w-full bg-cover bg-center bg-fixed',
fullHeight ? 'h-screen' : 'h-[60vh] min-h-[500px]'
)}
style={{
backgroundImage: backgroundUrl ? `url(${backgroundUrl})` : undefined,
backgroundColor: !backgroundUrl ? '#111' : undefined,
}}
>
{/* Overlay */}
<div
className={cn(
'absolute inset-0',
isDark ? 'bg-dark/60' : 'bg-light/60'
)}
/>
{/* Content */}
<div className="table absolute inset-0 w-full h-full">
<div
className={cn(
'table-cell w-full h-full align-middle px-4',
alignment === 'center' ? 'text-center' : 'text-left pl-[10%]'
)}
>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
{/* Title */}
<h1
className={cn(
'font-heading font-bold mb-[50px]',
'text-[3em] md:text-[5em] tracking-[10px] md:tracking-[15px]',
isDark ? 'text-white' : 'text-dark'
)}
>
{headline}
</h1>
{/* Subtitle */}
{subheadline && (
<p
className={cn(
'text-[1em] md:text-[1.2em] tracking-[2px] mb-5',
isDark ? 'text-gray-light' : 'text-gray'
)}
>
{subheadline}
</p>
)}
{/* CTA */}
{ctaLabel && ctaLink && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.6 }}
className="mt-10"
>
<Button
href={ctaLink}
variant={isDark ? 'ghost-light' : 'ghost'}
size="large"
>
{ctaLabel}
</Button>
</motion.div>
)}
</motion.div>
</div>
</div>
{/* Scroll Indicator */}
{fullHeight && (
<motion.a
href="#content"
className={cn(
'absolute bottom-[30px] left-1/2 -translate-x-1/2',
'text-[40px] transition-colors duration-500 border-none',
isDark ? 'text-white/50 hover:text-white' : 'text-dark/50 hover:text-dark'
)}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1, duration: 0.6 }}
>
<span className="block animate-bounce">&#8595;</span>
</motion.a>
)}
</section>
)
}

View file

@ -0,0 +1,95 @@
'use client'
import { motion } from 'framer-motion'
import Image from 'next/image'
import { Button } from '../ui/Button'
import { cn } from '@/lib/utils'
interface ImageTextBlockProps {
block: Record<string, unknown>
}
export function ImageTextBlock({ block }: ImageTextBlockProps) {
const title = (block.headline as string) || (block.title as string) || ''
const subtitle = (block.subheadline as string) || (block.subtitle as string) || ''
const content = (block.content as string) || (block.text as string) || ''
const media = block.image as Record<string, unknown> | undefined
const imageUrl = (media?.url as string) || ''
const imageAlt = (media?.alt as string) || title
const imagePosition = (block.imagePosition as string) || 'left'
const ctaLabel = (block.ctaLabel as string) || (block.buttonText as string) || ''
const ctaLink = (block.ctaLink as string) || (block.buttonLink as string) || ''
const backgroundColor = (block.backgroundColor as string) || 'light'
const isImageLeft = imagePosition === 'left'
return (
<section className={cn(backgroundColor === 'light' && 'bg-light-bg')}>
<div className="grid grid-cols-1 lg:grid-cols-2">
{/* Image */}
<motion.div
className={cn(
'relative h-[300px] lg:h-[450px] overflow-hidden',
isImageLeft ? 'lg:order-1' : 'lg:order-2'
)}
initial={{ opacity: 0, x: isImageLeft ? -30 : 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
{imageUrl ? (
<Image
src={imageUrl}
alt={imageAlt}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-light" />
)}
</motion.div>
{/* Content */}
<motion.div
className={cn(
'py-[75px] px-8 lg:px-[100px] lg:pr-[15%]',
'h-auto lg:h-[450px]',
'bg-light-bg flex flex-col justify-center',
isImageLeft ? 'lg:order-2' : 'lg:order-1'
)}
initial={{ opacity: 0, x: isImageLeft ? 30 : -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
>
{/* Subtitle */}
{subtitle && (
<span className="font-heading text-[0.85em] tracking-[2px] uppercase text-gray-light mb-0">
{subtitle}
</span>
)}
{/* Title */}
<h2 className="font-heading font-bold text-[1.5em] tracking-[4px] uppercase text-dark mt-[15px] mb-[50px]">
{title}
</h2>
{/* Content */}
<div
className="font-body text-gray leading-[1.8em] mb-[45px]"
dangerouslySetInnerHTML={{ __html: content }}
/>
{/* CTA */}
{ctaLabel && ctaLink && (
<div>
<Button href={ctaLink} variant="ghost">
{ctaLabel}
</Button>
</div>
)}
</motion.div>
</div>
</section>
)
}

View file

@ -0,0 +1,81 @@
'use client'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
interface QuoteBlockProps {
block: Record<string, unknown>
}
export function QuoteBlock({ block }: QuoteBlockProps) {
const quote = (block.quote as string) || (block.text as string) || ''
const author = (block.author as string) || (block.attribution as string) || ''
const role = (block.role as string) || ''
const backgroundMedia = block.backgroundImage as Record<string, unknown> | undefined
const backgroundUrl = backgroundMedia?.url as string | undefined
const style = (block.style as string) || (backgroundUrl ? 'parallax' : 'simple')
if (style === 'simple') {
return (
<section className="py-ws-m bg-light-bg">
<div className="max-w-3xl mx-auto px-4 text-center">
<blockquote className="text-[1.4em] leading-[1.6em] text-gray italic mb-8">
&ldquo;{quote}&rdquo;
</blockquote>
<footer>
<cite className="font-heading text-[1em] tracking-[2px] uppercase text-dark not-italic block mb-2">
{author}
</cite>
{role && (
<span className="font-heading text-[0.85em] tracking-[2px] uppercase text-gray-light">
{role}
</span>
)}
</footer>
</div>
</section>
)
}
return (
<section
className="relative bg-cover bg-center bg-fixed"
style={{
backgroundImage: backgroundUrl ? `url(${backgroundUrl})` : undefined,
backgroundColor: !backgroundUrl ? '#111' : undefined,
}}
>
{/* Overlay */}
<div className="absolute inset-0 bg-dark/50" />
{/* Content */}
<motion.div
className="relative py-ws-m w-[90%] md:w-[55%] mx-auto text-center"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
{/* Quote Icon */}
<span className="block text-[34px] text-white/50 mb-6">&#10077;</span>
{/* Quote */}
<blockquote className="text-[1.2em] md:text-[1.4em] leading-[1.6em] text-white mb-8">
&ldquo;{quote}&rdquo;
</blockquote>
{/* Author */}
<footer>
<cite className="font-heading text-[1em] tracking-[2px] uppercase text-white not-italic block mb-2">
{author}
</cite>
{role && (
<span className="font-heading text-[0.85em] tracking-[2px] uppercase text-gray-light">
{role}
</span>
)}
</footer>
</motion.div>
</section>
)
}

View file

@ -0,0 +1,44 @@
import { Container } from '../ui/Container'
import { prose } from '@/lib/typography'
import { cn } from '@/lib/utils'
interface TextBlockProps {
block: Record<string, unknown>
}
export function TextBlock({ block }: TextBlockProps) {
// Handle Lexical richText content
const richText = block.content || block.richText
let htmlContent = ''
if (typeof richText === 'string') {
htmlContent = richText
} else if (richText && typeof richText === 'object' && 'root' in (richText as Record<string, unknown>)) {
// Lexical JSON - render as placeholder
htmlContent = '<p>Rich-Text-Inhalt wird geladen...</p>'
}
const alignment = (block.alignment as string) || 'left'
const width = (block.width as 'narrow' | 'default' | 'wide') || 'default'
const backgroundColor = (block.backgroundColor as string) || 'white'
return (
<section
className={cn(
'py-ws-m',
backgroundColor === 'light' && 'bg-light-bg'
)}
id="content"
>
<Container width={width}>
<div
className={cn(
prose,
alignment === 'center' && 'text-center'
)}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</Container>
</section>
)
}

View file

@ -0,0 +1,77 @@
'use client'
import { motion } from 'framer-motion'
import { Container } from '../ui/Container'
import { SectionHeader } from '../ui/SectionHeader'
import { cn } from '@/lib/utils'
interface TimelineBlockProps {
block: Record<string, unknown>
}
export function TimelineBlock({ block }: TimelineBlockProps) {
const title = (block.title as string) || (block.headline as string) || ''
const subtitle = (block.subtitle as string) || ''
const items = (block.items as Array<Record<string, unknown>>) || (block.events as Array<Record<string, unknown>>) || []
return (
<section className="py-ws-m">
<Container width="narrow">
{(title || subtitle) && (
<SectionHeader title={title} subtitle={subtitle} />
)}
<div className="relative">
{/* Vertical Line */}
<div className="absolute left-4 md:left-1/2 top-0 bottom-0 w-px bg-light md:-translate-x-px" />
{/* Items */}
<div className="space-y-12">
{items.map((item, index) => {
const year = (item.year as string) || (item.date as string) || ''
const itemTitle = (item.title as string) || ''
const description = (item.description as string) || (item.text as string) || ''
return (
<motion.div
key={index}
className="relative pl-12 md:pl-0 md:grid md:grid-cols-2 md:gap-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1, duration: 0.5 }}
>
{/* Dot */}
<div className="absolute left-0 md:left-1/2 w-3 h-3 bg-dark rounded-full md:-translate-x-1/2 mt-1" />
{/* Year (left on desktop) */}
<div className={cn(
'md:text-right md:pr-8',
index % 2 === 1 && 'md:order-2 md:text-left md:pl-8 md:pr-0'
)}>
<span className="font-heading font-bold text-[1em] tracking-[2px] uppercase text-accent">
{year}
</span>
</div>
{/* Content (right on desktop) */}
<div className={cn(
'md:pl-8',
index % 2 === 1 && 'md:order-1 md:pr-8 md:pl-0 md:text-right'
)}>
<h3 className="font-heading font-bold text-[1.07em] tracking-[3px] uppercase text-dark mb-2">
{itemTitle}
</h3>
<p className="text-gray leading-[1.8em]">
{description}
</p>
</div>
</motion.div>
)
})}
</div>
</div>
</Container>
</section>
)
}

View file

@ -1,40 +1,71 @@
/** /**
* Block Renderer for porwoll.de * Block Renderer for porwoll.de
* *
* Uses @c2s/payload-contracts registry. * Maps CMS block types to Definity Template components.
* Blocks will be implemented as CMS content is created. * Uses @c2s/payload-contracts Block type for type safety.
* Unregistered blocks are silently skipped.
*/ */
import { createBlockRenderer } from "@c2s/payload-contracts/blocks" import type { Block } from '@c2s/payload-contracts/types'
// Placeholder component for blocks not yet implemented // Definity Design System Blocks
function Placeholder(props: Record<string, unknown>) { import { HeroBlock } from './HeroBlock'
if (process.env.NODE_ENV === "development") { import { TextBlock } from './TextBlock'
import { ImageTextBlock } from './ImageTextBlock'
import { CardGridBlock } from './CardGridBlock'
import { CTABlock } from './CTABlock'
import { DividerBlock } from './DividerBlock'
import { QuoteBlock } from './QuoteBlock'
import { ContactFormBlock } from './ContactFormBlock'
import { TimelineBlock } from './TimelineBlock'
// Block component registry
const blockComponents: Record<string, React.ComponentType<{ block: Record<string, unknown> }>> = {
'hero-block': HeroBlock,
'text-block': TextBlock,
'image-text-block': ImageTextBlock,
'card-grid-block': CardGridBlock,
'cta-block': CTABlock,
'divider-block': DividerBlock,
'quote-block': QuoteBlock,
'contact-form-block': ContactFormBlock,
'timeline-block': TimelineBlock,
}
// Placeholder for blocks not yet implemented
function PlaceholderBlock({ block }: { block: Record<string, unknown> }) {
if (process.env.NODE_ENV === 'development') {
return ( return (
<div style={{ padding: "2rem", background: "#f5f5f5", border: "1px dashed #ccc", margin: "1rem 0" }}> <div className="py-ws-s bg-light-bg border border-dashed border-light">
<strong>Block (not implemented)</strong> <div className="max-w-[1200px] mx-auto px-4 text-center">
<pre style={{ fontSize: "0.75rem", marginTop: "0.5rem" }}>{JSON.stringify(props, null, 2).slice(0, 200)}</pre> <p className="font-heading text-[0.85em] tracking-[2px] uppercase text-gray-light">
Block: {block.blockType as string}
</p>
</div>
</div> </div>
) )
} }
return null return null
} }
// porwoll.de blocks — replace placeholders with real components interface BlockRendererProps {
export const BlockRenderer = createBlockRenderer({ blocks: Block[]
"hero-block": Placeholder as any, }
"text-block": Placeholder as any,
"image-text-block": Placeholder as any, export function BlockRenderer({ blocks }: BlockRendererProps) {
"card-grid-block": Placeholder as any, if (!blocks || blocks.length === 0) return null
"cta-block": Placeholder as any,
"divider-block": Placeholder as any, return (
"testimonials-block": Placeholder as any, <>
"faq-block": Placeholder as any, {blocks.map((block, index) => {
"contact-form-block": Placeholder as any, const blockType = block.blockType
"image-slider-block": Placeholder as any, const Component = blockComponents[blockType] || PlaceholderBlock
"services-block": Placeholder as any,
"team-block": Placeholder as any, return (
"locations-block": Placeholder as any, <Component
"stats-block": Placeholder as any, key={block.id || `block-${index}`}
"quote-block": Placeholder as any, block={block as unknown as Record<string, unknown>}
}) />
)
})}
</>
)
}

View file

@ -0,0 +1,68 @@
'use client'
import Link from 'next/link'
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
type ButtonVariant = 'default' | 'ghost' | 'ghost-light' | 'light' | 'text' | 'text-light'
type ButtonSize = 'small' | 'default' | 'large'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
rounded?: boolean
href?: string
children: React.ReactNode
}
const baseStyles = `
relative inline-block
font-heading uppercase
tracking-[2.5px] text-[0.9em] leading-[1.8em]
transition-all duration-500
cursor-pointer
border-2
`
const sizeStyles: Record<ButtonSize, string> = {
small: "px-[33px] py-[6px] text-[0.75em]",
default: "px-[38px] py-[8px]",
large: "px-[54px] py-[10px] text-[1.1em] tracking-[3px]",
}
const variantStyles: Record<ButtonVariant, string> = {
default: "bg-dark text-light border-dark hover:bg-[#2f2f2f] hover:border-[#2f2f2f]",
ghost: "bg-transparent text-dark border-dark hover:bg-dark hover:text-light",
'ghost-light': "bg-transparent text-white border-white hover:bg-white hover:text-dark",
light: "bg-light text-dark border-light hover:bg-[#bebebe] hover:border-[#bebebe]",
text: "bg-transparent text-gray border-transparent hover:text-dark hover:border-dark",
'text-light': "bg-transparent text-gray-light border-transparent hover:text-white hover:border-white",
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'default', size = 'default', rounded = false, href, className, children, ...props }, ref) => {
const classes = cn(
baseStyles,
sizeStyles[size],
variantStyles[variant],
rounded && 'rounded-[25px]',
className
)
if (href) {
return (
<Link href={href} className={classes}>
{children}
</Link>
)
}
return (
<button ref={ref} className={classes} {...props}>
{children}
</button>
)
}
)
Button.displayName = 'Button'

View file

@ -0,0 +1,25 @@
import { ReactNode } from 'react'
import { cn } from '@/lib/utils'
type ContainerWidth = 'narrow' | 'default' | 'wide' | 'full'
interface ContainerProps {
children: ReactNode
width?: ContainerWidth
className?: string
}
const widthStyles: Record<ContainerWidth, string> = {
narrow: 'max-w-2xl',
default: 'max-w-[1200px]',
wide: 'max-w-7xl',
full: 'max-w-full',
}
export function Container({ children, width = 'default', className }: ContainerProps) {
return (
<div className={cn('mx-auto px-4 md:px-6', widthStyles[width], className)}>
{children}
</div>
)
}

View file

@ -0,0 +1,47 @@
import { cn } from '@/lib/utils'
interface SectionHeaderProps {
title: string
subtitle?: string
alignment?: 'left' | 'center'
light?: boolean
className?: string
}
export function SectionHeader({
title,
subtitle,
alignment = 'center',
light = false,
className,
}: SectionHeaderProps) {
return (
<header
className={cn(
'mb-[50px]',
alignment === 'center' ? 'text-center' : 'text-left',
className
)}
>
<h2
className={cn(
'font-heading font-bold text-[1.5em] tracking-[4px] uppercase mb-5',
light ? 'text-white' : 'text-dark'
)}
>
{title}
</h2>
{subtitle && (
<p
className={cn(
'font-heading-alt font-light text-[1em] tracking-[2px] uppercase',
light ? 'text-gray' : 'text-gray-light'
)}
>
{subtitle}
</p>
)}
</header>
)
}

64
src/lib/animations.ts Normal file
View file

@ -0,0 +1,64 @@
import type { Variants } from 'framer-motion'
/**
* Fade In Up - Standard Section Animation
*/
export const fadeInUp: Variants = {
initial: { opacity: 0, y: 30 },
animate: { opacity: 1, y: 0 },
}
export const fadeInUpProps = {
initial: 'initial',
whileInView: 'animate',
viewport: { once: true, margin: '-100px' },
transition: { duration: 0.6, ease: 'easeOut' },
}
/**
* Stagger Children
*/
export const staggerContainer: Variants = {
initial: {},
animate: {
transition: {
staggerChildren: 0.1,
},
},
}
/**
* Scale In
*/
export const scaleIn: Variants = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
}
/**
* Slide In from Left/Right
*/
export const slideInLeft: Variants = {
initial: { opacity: 0, x: -30 },
animate: { opacity: 1, x: 0 },
}
export const slideInRight: Variants = {
initial: { opacity: 0, x: 30 },
animate: { opacity: 1, x: 0 },
}
/**
* Card Hover Effect (Definity Style)
*/
export const cardHover = {
rest: {
y: 0,
boxShadow: '0px 1px 1px rgba(0, 0, 0, 0.2)',
},
hover: {
y: -10,
boxShadow: '0 22px 43px rgba(0, 0, 0, 0.15)',
transition: { duration: 0.3 },
},
}

View file

@ -50,7 +50,7 @@ export async function getPosts(options: {
} }
// Navigation // Navigation
export async function getNavigation(type: "header" | "footer" | "mobile") { export async function getNavigation(type: "header" | "footer" | "mobile" = "header") {
try { try {
return await cms.navigation.getNavigation(type, { depth: 2 }) return await cms.navigation.getNavigation(type, { depth: 2 })
} catch { } catch {
@ -76,6 +76,25 @@ export async function getSeoSettings() {
} }
} }
// Social Links
export async function getSocialLinks(): Promise<{ platform: string; url: string }[]> {
try {
const result = await cms.client.getCollection("social-links", {
limit: 20,
depth: 1,
})
return (result?.docs || []).map((link: unknown) => {
const l = link as Record<string, unknown>
return {
platform: (l.platform as string) || "",
url: (l.url as string) || "",
}
})
} catch {
return []
}
}
// Testimonials // Testimonials
export async function getTestimonials(options: { limit?: number; locale?: string } = {}) { export async function getTestimonials(options: { limit?: number; locale?: string } = {}) {
return cms.client.getCollection("testimonials", { return cms.client.getCollection("testimonials", {

65
src/lib/typography.ts Normal file
View file

@ -0,0 +1,65 @@
/**
* Definity Typography Classes
* Basierend auf Original Definity Template
*/
export const typography = {
// Headings
h1: "font-heading font-bold text-[1.7em] tracking-[5px] uppercase text-dark",
h2: "font-heading font-bold text-[1.5em] tracking-[4px] uppercase text-dark",
h3: "font-heading font-bold text-[1.3em] tracking-[3.5px] uppercase text-dark",
h4: "font-heading font-bold text-[1.07em] tracking-[3px] uppercase text-dark",
h5: "font-heading font-bold text-[1em] tracking-[2px] uppercase text-dark",
h6: "font-heading font-bold text-[0.85em] tracking-[2px] uppercase text-dark",
// Light Variants (for dark backgrounds)
h1Light: "font-heading font-bold text-[1.7em] tracking-[5px] uppercase text-white",
h2Light: "font-heading font-bold text-[1.5em] tracking-[4px] uppercase text-white",
h3Light: "font-heading font-bold text-[1.3em] tracking-[3.5px] uppercase text-white",
// Alternative Heading (Light Weight)
hAlt: "font-heading-alt font-light uppercase tracking-[2px]",
hAltLight: "font-heading-alt font-light uppercase tracking-[2px] text-gray-light",
// Body
body: "font-body font-light text-[14px] tracking-[0.2px] leading-[1.8em] text-gray",
bodyLight: "font-body font-light text-[14px] tracking-[0.2px] leading-[1.8em] text-gray-light",
// Links
link: "text-accent border-b border-transparent hover:border-accent transition-all duration-500",
linkDark: "text-dark border-b border-transparent hover:border-dark transition-all duration-500",
// Labels / Small Text
label: "font-heading text-[0.85em] tracking-[2px] uppercase text-gray-light",
labelDark: "font-heading text-[0.85em] tracking-[2px] uppercase text-dark",
}
/**
* Prose Styles for Rich Text Content
*/
export const prose = `
font-body font-light text-[14px] tracking-[0.2px] leading-[1.8em] text-gray
[&_h1]:font-heading [&_h1]:font-bold [&_h1]:text-[1.7em]
[&_h1]:tracking-[5px] [&_h1]:uppercase [&_h1]:text-dark [&_h1]:mb-6
[&_h2]:font-heading [&_h2]:font-bold [&_h2]:text-[1.5em]
[&_h2]:tracking-[4px] [&_h2]:uppercase [&_h2]:text-dark [&_h2]:mb-5 [&_h2]:mt-10
[&_h3]:font-heading [&_h3]:font-bold [&_h3]:text-[1.3em]
[&_h3]:tracking-[3.5px] [&_h3]:uppercase [&_h3]:text-dark [&_h3]:mb-4 [&_h3]:mt-8
[&_p]:mb-6
[&_a]:text-accent [&_a]:border-b [&_a]:border-transparent
[&_a:hover]:border-accent [&_a]:transition-all [&_a]:duration-500
[&_blockquote]:border-none [&_blockquote]:p-0 [&_blockquote]:my-8
[&_blockquote]:text-[1.3em] [&_blockquote]:tracking-[0.5px] [&_blockquote]:italic
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-6
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-6
[&_li]:mb-2
[&_strong]:font-semibold [&_strong]:text-dark
`

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}