mirror of
https://github.com/complexcaresolutions/frontend.porwoll.de.git
synced 2026-03-17 18:43:42 +00:00
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:
parent
e998553440
commit
2e8d34d917
26 changed files with 1795 additions and 98 deletions
|
|
@ -10,9 +10,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@c2s/payload-contracts": "github:complexcaresolutions/payload-contracts",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.0",
|
||||
"next": "16.0.10",
|
||||
"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": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ importers:
|
|||
'@c2s/payload-contracts':
|
||||
specifier: github:complexcaresolutions/payload-contracts
|
||||
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:
|
||||
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)
|
||||
|
|
@ -20,6 +26,12 @@ importers:
|
|||
react-dom:
|
||||
specifier: 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:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
|
|
@ -811,6 +823,10 @@ packages:
|
|||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
|
@ -1100,6 +1116,20 @@ packages:
|
|||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
|
|
@ -1487,6 +1517,12 @@ packages:
|
|||
minimist@1.2.8:
|
||||
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:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
|
|
@ -1632,6 +1668,11 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.2.1
|
||||
|
||||
react-icons@5.5.0:
|
||||
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
|
|
@ -1797,6 +1838,9 @@ packages:
|
|||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
tailwind-merge@3.4.1:
|
||||
resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==}
|
||||
|
||||
tailwindcss@4.1.18:
|
||||
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
||||
|
||||
|
|
@ -2665,6 +2709,8 @@ snapshots:
|
|||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
|
@ -3100,6 +3146,15 @@ snapshots:
|
|||
dependencies:
|
||||
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.prototype.name@1.1.8:
|
||||
|
|
@ -3466,6 +3521,12 @@ snapshots:
|
|||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
motion-dom@12.34.0:
|
||||
dependencies:
|
||||
motion-utils: 12.29.2
|
||||
|
||||
motion-utils@12.29.2: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
|
@ -3611,6 +3672,10 @@ snapshots:
|
|||
react: 19.2.1
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-icons@5.5.0(react@19.2.1):
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react@19.2.1: {}
|
||||
|
|
@ -3846,6 +3911,8 @@ snapshots:
|
|||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
tailwind-merge@3.4.1: {}
|
||||
|
||||
tailwindcss@4.1.18: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,29 @@ import { BlockRenderer } from "@/components/blocks"
|
|||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
interface Props {
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const data = await getPages({ limit: 100 })
|
||||
return data.docs
|
||||
.filter((p) => p.slug !== "home")
|
||||
try {
|
||||
const pages = await getPages({ limit: 100 })
|
||||
return (pages?.docs || [])
|
||||
.filter((p) => p.slug && p.slug !== "home")
|
||||
.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 [page, seoSettings] = await Promise.all([getPage(slug), getSeoSettings()])
|
||||
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 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 page = await getPage(slug)
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
}
|
||||
if (!page) notFound()
|
||||
|
||||
return (
|
||||
<main>
|
||||
<BlockRenderer blocks={page.layout} />
|
||||
</main>
|
||||
)
|
||||
return <BlockRenderer blocks={page.layout || []} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,127 @@
|
|||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* Tailwind v4 Theme Configuration */
|
||||
@theme {
|
||||
/* 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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* Base Styles */
|
||||
@layer base {
|
||||
html {
|
||||
font-size: 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 300;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Component Layer */
|
||||
@layer components {
|
||||
.container-def {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,84 @@
|
|||
import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import { getSiteSettings } from "@/lib/api"
|
||||
import type { Metadata } from 'next'
|
||||
import { Montserrat, Open_Sans } from 'next/font/google'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { getNavigation, getSiteSettings, getSocialLinks } from '@/lib/api'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
const montserrat = Montserrat({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '700'],
|
||||
variable: '--font-montserrat',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getSiteSettings()
|
||||
return {
|
||||
title: {
|
||||
default: settings?.siteName || "Caroline Porwoll",
|
||||
template: `%s | ${settings?.siteName || "Caroline Porwoll"}`,
|
||||
},
|
||||
description: "Professionelle Portrait- und Businessfotografie",
|
||||
}
|
||||
const openSans = Open_Sans({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '600', '700', '800'],
|
||||
variable: '--font-open-sans',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Martin Porwoll',
|
||||
description: 'Whistleblower. Unternehmer. Mensch.',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
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 (
|
||||
<html lang="de">
|
||||
<body className={`${inter.variable} antialiased`}>{children}</body>
|
||||
<html lang="de" className={`${montserrat.variable} ${openSans.variable}`}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { getPage, getSeoSettings } from "@/lib/api"
|
||||
import { BlockRenderer } from "@/components/blocks"
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 description = page.seo?.metaDescription || ""
|
||||
|
||||
|
|
@ -22,18 +22,24 @@ export default async function HomePage() {
|
|||
|
||||
if (!page) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Caroline Porwoll</h1>
|
||||
<p className="text-lg text-gray-600">Website wird vorbereitet...</p>
|
||||
<section
|
||||
className="relative h-screen w-full bg-cover bg-center bg-fixed"
|
||||
style={{ backgroundColor: '#111' }}
|
||||
>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<BlockRenderer blocks={page.layout} />
|
||||
</main>
|
||||
)
|
||||
return <BlockRenderer blocks={page.layout || []} />
|
||||
}
|
||||
|
|
|
|||
15
src/app/template.tsx
Normal file
15
src/app/template.tsx
Normal 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
196
src/components/Footer.tsx
Normal 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">
|
||||
© {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>
|
||||
)
|
||||
}
|
||||
178
src/components/Navigation.tsx
Normal file
178
src/components/Navigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
src/components/blocks/CTABlock.tsx
Normal file
51
src/components/blocks/CTABlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
src/components/blocks/CardGridBlock.tsx
Normal file
115
src/components/blocks/CardGridBlock.tsx
Normal 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} →
|
||||
</Link>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
152
src/components/blocks/ContactFormBlock.tsx
Normal file
152
src/components/blocks/ContactFormBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/components/blocks/DividerBlock.tsx
Normal file
20
src/components/blocks/DividerBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
src/components/blocks/HeroBlock.tsx
Normal file
118
src/components/blocks/HeroBlock.tsx
Normal 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">↓</span>
|
||||
</motion.a>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
95
src/components/blocks/ImageTextBlock.tsx
Normal file
95
src/components/blocks/ImageTextBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
src/components/blocks/QuoteBlock.tsx
Normal file
81
src/components/blocks/QuoteBlock.tsx
Normal 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">
|
||||
“{quote}”
|
||||
</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">❝</span>
|
||||
|
||||
{/* Quote */}
|
||||
<blockquote className="text-[1.2em] md:text-[1.4em] leading-[1.6em] text-white mb-8">
|
||||
“{quote}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
44
src/components/blocks/TextBlock.tsx
Normal file
44
src/components/blocks/TextBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/components/blocks/TimelineBlock.tsx
Normal file
77
src/components/blocks/TimelineBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,40 +1,71 @@
|
|||
/**
|
||||
* Block Renderer for porwoll.de
|
||||
*
|
||||
* Uses @c2s/payload-contracts registry.
|
||||
* Blocks will be implemented as CMS content is created.
|
||||
* Unregistered blocks are silently skipped.
|
||||
* Maps CMS block types to Definity Template components.
|
||||
* Uses @c2s/payload-contracts Block type for type safety.
|
||||
*/
|
||||
import { createBlockRenderer } from "@c2s/payload-contracts/blocks"
|
||||
import type { Block } from '@c2s/payload-contracts/types'
|
||||
|
||||
// Placeholder component for blocks not yet implemented
|
||||
function Placeholder(props: Record<string, unknown>) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// Definity Design System Blocks
|
||||
import { HeroBlock } from './HeroBlock'
|
||||
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 (
|
||||
<div style={{ padding: "2rem", background: "#f5f5f5", border: "1px dashed #ccc", margin: "1rem 0" }}>
|
||||
<strong>Block (not implemented)</strong>
|
||||
<pre style={{ fontSize: "0.75rem", marginTop: "0.5rem" }}>{JSON.stringify(props, null, 2).slice(0, 200)}</pre>
|
||||
<div className="py-ws-s bg-light-bg border border-dashed border-light">
|
||||
<div className="max-w-[1200px] mx-auto px-4 text-center">
|
||||
<p className="font-heading text-[0.85em] tracking-[2px] uppercase text-gray-light">
|
||||
Block: {block.blockType as string}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// porwoll.de blocks — replace placeholders with real components
|
||||
export const BlockRenderer = createBlockRenderer({
|
||||
"hero-block": Placeholder as any,
|
||||
"text-block": Placeholder as any,
|
||||
"image-text-block": Placeholder as any,
|
||||
"card-grid-block": Placeholder as any,
|
||||
"cta-block": Placeholder as any,
|
||||
"divider-block": Placeholder as any,
|
||||
"testimonials-block": Placeholder as any,
|
||||
"faq-block": Placeholder as any,
|
||||
"contact-form-block": Placeholder as any,
|
||||
"image-slider-block": Placeholder as any,
|
||||
"services-block": Placeholder as any,
|
||||
"team-block": Placeholder as any,
|
||||
"locations-block": Placeholder as any,
|
||||
"stats-block": Placeholder as any,
|
||||
"quote-block": Placeholder as any,
|
||||
})
|
||||
interface BlockRendererProps {
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
export function BlockRenderer({ blocks }: BlockRendererProps) {
|
||||
if (!blocks || blocks.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) => {
|
||||
const blockType = block.blockType
|
||||
const Component = blockComponents[blockType] || PlaceholderBlock
|
||||
|
||||
return (
|
||||
<Component
|
||||
key={block.id || `block-${index}`}
|
||||
block={block as unknown as Record<string, unknown>}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
68
src/components/ui/Button.tsx
Normal file
68
src/components/ui/Button.tsx
Normal 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'
|
||||
25
src/components/ui/Container.tsx
Normal file
25
src/components/ui/Container.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
src/components/ui/SectionHeader.tsx
Normal file
47
src/components/ui/SectionHeader.tsx
Normal 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
64
src/lib/animations.ts
Normal 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 },
|
||||
},
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ export async function getPosts(options: {
|
|||
}
|
||||
|
||||
// Navigation
|
||||
export async function getNavigation(type: "header" | "footer" | "mobile") {
|
||||
export async function getNavigation(type: "header" | "footer" | "mobile" = "header") {
|
||||
try {
|
||||
return await cms.navigation.getNavigation(type, { depth: 2 })
|
||||
} 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
|
||||
export async function getTestimonials(options: { limit?: number; locale?: string } = {}) {
|
||||
return cms.client.getCollection("testimonials", {
|
||||
|
|
|
|||
65
src/lib/typography.ts
Normal file
65
src/lib/typography.ts
Normal 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
6
src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
Loading…
Reference in a new issue