diff --git a/src/app/(frontend)/[locale]/layout.tsx b/src/app/(frontend)/[locale]/layout.tsx
new file mode 100644
index 0000000..cc869ae
--- /dev/null
+++ b/src/app/(frontend)/[locale]/layout.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import { notFound } from 'next/navigation'
+import { isValidLocale, locales, getLocaleDirection, type Locale } from '@/lib/i18n'
+
+export function generateStaticParams() {
+ return locales.map((locale) => ({ locale }))
+}
+
+interface LocaleLayoutProps {
+ children: React.ReactNode
+ params: Promise<{ locale: string }>
+}
+
+export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
+ const { locale } = await params
+
+ if (!isValidLocale(locale)) {
+ notFound()
+ }
+
+ const direction = getLocaleDirection(locale as Locale)
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/app/(frontend)/[locale]/page.tsx b/src/app/(frontend)/[locale]/page.tsx
new file mode 100644
index 0000000..be172d8
--- /dev/null
+++ b/src/app/(frontend)/[locale]/page.tsx
@@ -0,0 +1,100 @@
+import { headers as getHeaders } from 'next/headers'
+import Image from 'next/image'
+import { getPayload } from 'payload'
+import React from 'react'
+import { fileURLToPath } from 'url'
+import { notFound } from 'next/navigation'
+
+import config from '@/payload.config'
+import { isValidLocale, localeNames, type Locale } from '@/lib/i18n'
+import '../styles.css'
+
+interface HomePageProps {
+ params: Promise<{ locale: string }>
+}
+
+export default async function HomePage({ params }: HomePageProps) {
+ const { locale } = await params
+
+ if (!isValidLocale(locale)) {
+ notFound()
+ }
+
+ const headers = await getHeaders()
+ const payloadConfig = await config
+ const payload = await getPayload({ config: payloadConfig })
+ const { user } = await payload.auth({ headers })
+
+ const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
+
+ // Localized content
+ const content = {
+ de: {
+ welcome: 'Willkommen zu Ihrem neuen Projekt.',
+ welcomeBack: 'Willkommen zurück,',
+ adminPanel: 'Zum Admin-Panel',
+ documentation: 'Dokumentation',
+ updatePage: 'Diese Seite bearbeiten in',
+ },
+ en: {
+ welcome: 'Welcome to your new project.',
+ welcomeBack: 'Welcome back,',
+ adminPanel: 'Go to admin panel',
+ documentation: 'Documentation',
+ updatePage: 'Update this page by editing',
+ },
+ }
+
+ const t = content[locale as Locale]
+
+ return (
+
+
+
+
+
+
+ {!user &&
{t.welcome}
}
+ {user && (
+
+ {t.welcomeBack} {user.email}
+
+ )}
+
+
+
+ Current locale: {localeNames[locale as Locale].native}
+
+
+
+
+
+ )
+}
diff --git a/src/app/(frontend)/api/posts/route.ts b/src/app/(frontend)/api/posts/route.ts
new file mode 100644
index 0000000..401665e
--- /dev/null
+++ b/src/app/(frontend)/api/posts/route.ts
@@ -0,0 +1,136 @@
+// src/app/(frontend)/api/posts/route.ts
+// Posts listing with category and type filters
+
+import { NextRequest, NextResponse } from 'next/server'
+import { getPayload } from 'payload'
+import config from '@payload-config'
+import { getPostsByCategory, checkRateLimit } from '@/lib/search'
+
+// Validation constants
+const MAX_LIMIT = 50
+const DEFAULT_LIMIT = 10
+
+export async function GET(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+ request.headers.get('x-real-ip') ||
+ 'unknown'
+
+ const rateLimit = checkRateLimit(ip)
+
+ if (!rateLimit.allowed) {
+ return NextResponse.json(
+ { error: 'Too many requests. Please try again later.' },
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(rateLimit.retryAfter || 60),
+ 'X-RateLimit-Remaining': '0',
+ },
+ }
+ )
+ }
+
+ // Parse query parameters
+ const { searchParams } = new URL(request.url)
+ const category = searchParams.get('category')?.trim()
+ const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined
+ const tenantParam = searchParams.get('tenant')
+ const pageParam = searchParams.get('page')
+ const limitParam = searchParams.get('limit')
+ const localeParam = searchParams.get('locale')?.trim()
+
+ // Validate locale
+ const validLocales = ['de', 'en']
+ const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
+
+ // Validate type
+ const validTypes = ['blog', 'news', 'press', 'announcement']
+ if (type && !validTypes.includes(type)) {
+ return NextResponse.json(
+ { error: `Invalid type. Must be one of: ${validTypes.join(', ')}` },
+ { status: 400 }
+ )
+ }
+
+ // Parse numeric parameters
+ const page = Math.max(1, parseInt(pageParam || '1', 10) || 1)
+ const limit = Math.min(
+ Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
+ MAX_LIMIT
+ )
+ const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
+
+ // Validate tenant ID if provided
+ if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) {
+ return NextResponse.json(
+ { error: 'Invalid tenant ID' },
+ { status: 400 }
+ )
+ }
+
+ // Get payload instance
+ const payload = await getPayload({ config })
+
+ // Get posts
+ const result = await getPostsByCategory(payload, {
+ tenantId,
+ categorySlug: category,
+ type,
+ locale,
+ page,
+ limit,
+ })
+
+ // Transform response
+ const response = {
+ docs: result.docs.map((post) => ({
+ id: post.id,
+ title: post.title,
+ slug: post.slug,
+ excerpt: post.excerpt || null,
+ publishedAt: post.publishedAt || null,
+ type: (post as typeof post & { type?: string }).type || 'blog',
+ featuredImage: post.featuredImage && typeof post.featuredImage === 'object'
+ ? {
+ url: post.featuredImage.url,
+ alt: post.featuredImage.alt,
+ width: post.featuredImage.width,
+ height: post.featuredImage.height,
+ }
+ : null,
+ category: post.category && typeof post.category === 'object'
+ ? { name: post.category.name, slug: post.category.slug }
+ : null,
+ })),
+ pagination: {
+ page: result.page,
+ limit,
+ totalPages: result.totalPages,
+ totalDocs: result.totalDocs,
+ hasNextPage: result.hasNextPage,
+ hasPrevPage: result.hasPrevPage,
+ },
+ filters: {
+ category,
+ type,
+ locale,
+ tenant: tenantId,
+ },
+ }
+
+ return NextResponse.json(response, {
+ headers: {
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'Cache-Control': 'public, max-age=60, s-maxage=60',
+ },
+ })
+ } catch (error) {
+ console.error('[Posts API] Error:', error)
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/(frontend)/api/search/route.ts b/src/app/(frontend)/api/search/route.ts
new file mode 100644
index 0000000..43270ac
--- /dev/null
+++ b/src/app/(frontend)/api/search/route.ts
@@ -0,0 +1,118 @@
+// src/app/(frontend)/api/search/route.ts
+// Main search API endpoint
+
+import { NextRequest, NextResponse } from 'next/server'
+import { getPayload } from 'payload'
+import config from '@payload-config'
+import { searchPosts, checkRateLimit } from '@/lib/search'
+
+// Validation constants
+const MIN_QUERY_LENGTH = 2
+const MAX_QUERY_LENGTH = 100
+const MAX_LIMIT = 50
+const DEFAULT_LIMIT = 10
+
+export async function GET(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+ request.headers.get('x-real-ip') ||
+ 'unknown'
+
+ const rateLimit = checkRateLimit(ip)
+
+ if (!rateLimit.allowed) {
+ return NextResponse.json(
+ { error: 'Too many requests. Please try again later.' },
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(rateLimit.retryAfter || 60),
+ 'X-RateLimit-Remaining': '0',
+ },
+ }
+ )
+ }
+
+ // Parse query parameters
+ const { searchParams } = new URL(request.url)
+ const query = searchParams.get('q')?.trim() || ''
+ const category = searchParams.get('category')?.trim()
+ const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined
+ const tenantParam = searchParams.get('tenant')
+ const limitParam = searchParams.get('limit')
+ const offsetParam = searchParams.get('offset')
+ const localeParam = searchParams.get('locale')?.trim()
+
+ // Validate locale
+ const validLocales = ['de', 'en']
+ const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
+
+ // Validate query
+ if (query && query.length < MIN_QUERY_LENGTH) {
+ return NextResponse.json(
+ { error: `Query must be at least ${MIN_QUERY_LENGTH} characters` },
+ { status: 400 }
+ )
+ }
+
+ if (query && query.length > MAX_QUERY_LENGTH) {
+ return NextResponse.json(
+ { error: `Query must be at most ${MAX_QUERY_LENGTH} characters` },
+ { status: 400 }
+ )
+ }
+
+ // Validate type
+ const validTypes = ['blog', 'news', 'press', 'announcement']
+ if (type && !validTypes.includes(type)) {
+ return NextResponse.json(
+ { error: `Invalid type. Must be one of: ${validTypes.join(', ')}` },
+ { status: 400 }
+ )
+ }
+
+ // Parse numeric parameters
+ const limit = Math.min(
+ Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
+ MAX_LIMIT
+ )
+ const offset = Math.max(0, parseInt(offsetParam || '0', 10) || 0)
+ const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
+
+ // Validate tenant ID if provided
+ if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) {
+ return NextResponse.json(
+ { error: 'Invalid tenant ID' },
+ { status: 400 }
+ )
+ }
+
+ // Get payload instance
+ const payload = await getPayload({ config })
+
+ // Execute search
+ const result = await searchPosts(payload, {
+ query,
+ tenantId,
+ categorySlug: category,
+ type,
+ locale,
+ limit,
+ offset,
+ })
+
+ return NextResponse.json(result, {
+ headers: {
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'Cache-Control': 'public, max-age=60, s-maxage=60',
+ },
+ })
+ } catch (error) {
+ console.error('[Search API] Error:', error)
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/(frontend)/api/search/suggestions/route.ts b/src/app/(frontend)/api/search/suggestions/route.ts
new file mode 100644
index 0000000..55033aa
--- /dev/null
+++ b/src/app/(frontend)/api/search/suggestions/route.ts
@@ -0,0 +1,112 @@
+// src/app/(frontend)/api/search/suggestions/route.ts
+// Auto-complete suggestions endpoint
+
+import { NextRequest, NextResponse } from 'next/server'
+import { getPayload } from 'payload'
+import config from '@payload-config'
+import { getSearchSuggestions, checkRateLimit } from '@/lib/search'
+
+// Validation constants
+const MIN_QUERY_LENGTH = 2
+const MAX_QUERY_LENGTH = 50
+const MAX_LIMIT = 10
+const DEFAULT_LIMIT = 5
+
+export async function GET(request: NextRequest) {
+ try {
+ // Rate limiting
+ const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+ request.headers.get('x-real-ip') ||
+ 'unknown'
+
+ const rateLimit = checkRateLimit(ip)
+
+ if (!rateLimit.allowed) {
+ return NextResponse.json(
+ { suggestions: [] },
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(rateLimit.retryAfter || 60),
+ 'X-RateLimit-Remaining': '0',
+ },
+ }
+ )
+ }
+
+ // Parse query parameters
+ const { searchParams } = new URL(request.url)
+ const query = searchParams.get('q')?.trim() || ''
+ const category = searchParams.get('category')?.trim()
+ const tenantParam = searchParams.get('tenant')
+ const limitParam = searchParams.get('limit')
+ const localeParam = searchParams.get('locale')?.trim()
+
+ // Validate locale
+ const validLocales = ['de', 'en']
+ const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
+
+ // Validate query
+ if (!query || query.length < MIN_QUERY_LENGTH) {
+ return NextResponse.json(
+ { suggestions: [] },
+ {
+ headers: {
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'Cache-Control': 'public, max-age=60, s-maxage=60',
+ },
+ }
+ )
+ }
+
+ if (query.length > MAX_QUERY_LENGTH) {
+ return NextResponse.json(
+ { error: `Query must be at most ${MAX_QUERY_LENGTH} characters` },
+ { status: 400 }
+ )
+ }
+
+ // Parse numeric parameters
+ const limit = Math.min(
+ Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
+ MAX_LIMIT
+ )
+ const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
+
+ // Validate tenant ID if provided
+ if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) {
+ return NextResponse.json(
+ { error: 'Invalid tenant ID' },
+ { status: 400 }
+ )
+ }
+
+ // Get payload instance
+ const payload = await getPayload({ config })
+
+ // Get suggestions
+ const suggestions = await getSearchSuggestions(payload, {
+ query,
+ tenantId,
+ categorySlug: category,
+ locale,
+ limit,
+ })
+
+ return NextResponse.json(
+ { suggestions },
+ {
+ headers: {
+ 'X-RateLimit-Remaining': String(rateLimit.remaining),
+ 'Cache-Control': 'public, max-age=60, s-maxage=60',
+ },
+ }
+ )
+ } catch (error) {
+ console.error('[Suggestions API] Error:', error)
+ return NextResponse.json(
+ { suggestions: [] },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx
index e7681f7..2538e1a 100644
--- a/src/app/(frontend)/layout.tsx
+++ b/src/app/(frontend)/layout.tsx
@@ -2,18 +2,15 @@ import React from 'react'
import './styles.css'
export const metadata = {
- description: 'A blank template using Payload in a Next.js app.',
- title: 'Payload Blank Template',
+ description: 'Multi-Tenant CMS für mehrere Websites',
+ title: 'Payload CMS',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
- return (
-
-
- {children}
-
-
- )
+ // This layout wraps both localized and non-localized routes
+ // The [locale]/layout.tsx handles the html/body for localized routes
+ // For API routes etc, this provides basic wrapper
+ return <>{children}>
}
diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx
index d0e8d64..633b5be 100644
--- a/src/app/(frontend)/page.tsx
+++ b/src/app/(frontend)/page.tsx
@@ -1,59 +1,8 @@
-import { headers as getHeaders } from 'next/headers.js'
-import Image from 'next/image'
-import { getPayload } from 'payload'
-import React from 'react'
-import { fileURLToPath } from 'url'
+import { redirect } from 'next/navigation'
+import { defaultLocale } from '@/lib/i18n'
-import config from '@/payload.config'
-import './styles.css'
-
-export default async function HomePage() {
- const headers = await getHeaders()
- const payloadConfig = await config
- const payload = await getPayload({ config: payloadConfig })
- const { user } = await payload.auth({ headers })
-
- const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
-
- return (
-
-
-
-
-
-
- {!user &&
Welcome to your new project.
}
- {user &&
Welcome back, {user.email}
}
-
-
-
-
- )
+// This page catches requests to / and redirects to the default locale
+// The middleware should normally handle this, but this is a fallback
+export default function RootPage() {
+ redirect(`/${defaultLocale}`)
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
new file mode 100644
index 0000000..547a27c
--- /dev/null
+++ b/src/app/robots.ts
@@ -0,0 +1,40 @@
+import { MetadataRoute } from 'next'
+
+/**
+ * robots.txt Generierung
+ *
+ * Steuert das Crawling-Verhalten von Suchmaschinen.
+ * Für Multi-Tenant können individuelle Regeln pro Domain erstellt werden.
+ */
+export default function robots(): MetadataRoute.Robots {
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de'
+
+ return {
+ rules: [
+ {
+ userAgent: '*',
+ allow: '/',
+ disallow: [
+ '/admin',
+ '/admin/*',
+ '/api/*',
+ '/_next/*',
+ '/media/*', // Optional: Media-Ordner ausschließen
+ ],
+ },
+ // Spezifische Regeln für bekannte Bots
+ {
+ userAgent: 'Googlebot',
+ allow: '/',
+ disallow: ['/admin', '/api'],
+ },
+ {
+ userAgent: 'Bingbot',
+ allow: '/',
+ disallow: ['/admin', '/api'],
+ },
+ ],
+ sitemap: `${baseUrl}/sitemap.xml`,
+ host: baseUrl,
+ }
+}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 0000000..e42c2f9
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,87 @@
+import { MetadataRoute } from 'next'
+
+// Force dynamic rendering - don't generate at build time
+export const dynamic = 'force-dynamic'
+
+/**
+ * Dynamische Sitemap-Generierung
+ *
+ * Diese Funktion wird zur Laufzeit aufgerufen und generiert die Sitemap
+ * basierend auf den aktuellen Inhalten der Datenbank.
+ */
+export default async function sitemap(): Promise {
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de'
+
+ // Basis-Sitemap mit Startseite
+ const sitemapEntries: MetadataRoute.Sitemap = [
+ {
+ url: baseUrl,
+ lastModified: new Date(),
+ changeFrequency: 'daily',
+ priority: 1.0,
+ },
+ ]
+
+ // Dynamische Inhalte nur zur Laufzeit laden, nicht beim Build
+ if (process.env.NODE_ENV === 'production' && typeof window === 'undefined') {
+ try {
+ // Lazy import um Build-Zeit-Probleme zu vermeiden
+ const { getPayload } = await import('payload')
+ const config = (await import('@payload-config')).default
+
+ const payload = await getPayload({ config })
+
+ // Alle veröffentlichten Seiten abrufen
+ const pages = await payload.find({
+ collection: 'pages',
+ where: { status: { equals: 'published' } },
+ limit: 1000,
+ depth: 0,
+ })
+
+ for (const page of pages.docs) {
+ if (page.slug === 'home' || page.slug === 'startseite' || page.slug === '/') {
+ continue
+ }
+ sitemapEntries.push({
+ url: `${baseUrl}/${page.slug}`,
+ lastModified: new Date(page.updatedAt),
+ changeFrequency: 'weekly',
+ priority: 0.8,
+ })
+ }
+
+ // Alle veröffentlichten Blog-Posts abrufen
+ const posts = await payload.find({
+ collection: 'posts',
+ where: { status: { equals: 'published' } },
+ limit: 1000,
+ depth: 0,
+ })
+
+ for (const post of posts.docs) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const postType = (post as any).type || 'blog'
+ const urlPrefix =
+ postType === 'news'
+ ? 'news'
+ : postType === 'press'
+ ? 'presse'
+ : postType === 'announcement'
+ ? 'aktuelles'
+ : 'blog'
+
+ sitemapEntries.push({
+ url: `${baseUrl}/${urlPrefix}/${post.slug}`,
+ lastModified: new Date(post.updatedAt),
+ changeFrequency: 'monthly',
+ priority: 0.6,
+ })
+ }
+ } catch (error) {
+ console.warn('[Sitemap] Could not fetch dynamic content:', error)
+ }
+ }
+
+ return sitemapEntries
+}
diff --git a/src/instrumentation.ts b/src/instrumentation.ts
new file mode 100644
index 0000000..399c6cd
--- /dev/null
+++ b/src/instrumentation.ts
@@ -0,0 +1,24 @@
+/**
+ * Next.js Instrumentation Hook
+ *
+ * Wird beim Server-Start ausgeführt. Initialisiert Scheduled Jobs.
+ * https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
+ */
+export async function register() {
+ // Nur auf dem Server ausführen, nicht im Edge Runtime
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ const { getPayload } = await import('payload')
+ const config = await import('./payload.config')
+ const { initScheduledJobs } = await import('./jobs/scheduler')
+
+ // Payload initialisieren
+ const payload = await getPayload({
+ config: config.default,
+ })
+
+ // Scheduled Jobs starten
+ initScheduledJobs(payload)
+
+ console.log('[Instrumentation] Payload und Scheduled Jobs initialisiert.')
+ }
+}
diff --git a/src/lib/envValidation.ts b/src/lib/envValidation.ts
new file mode 100644
index 0000000..70470b3
--- /dev/null
+++ b/src/lib/envValidation.ts
@@ -0,0 +1,73 @@
+// src/lib/envValidation.ts
+
+/**
+ * Zentrale Validierung aller erforderlichen Environment-Variablen.
+ * Wird beim Server-Start aufgerufen und beendet den Prozess bei fehlenden Werten.
+ */
+
+interface RequiredEnvVars {
+ PAYLOAD_SECRET: string
+ DATABASE_URI: string
+ CONSENT_LOGGING_API_KEY: string
+ IP_ANONYMIZATION_PEPPER: string
+}
+
+const FORBIDDEN_VALUES = [
+ '',
+ 'default-pepper-change-me',
+ 'change-me',
+ 'your-secret-here',
+ 'xxx',
+]
+
+function validateEnvVar(name: string, value: string | undefined): string {
+ if (!value || value.trim() === '') {
+ throw new Error(
+ `FATAL: Environment variable ${name} is required but not set. ` +
+ `Server cannot start without this value.`,
+ )
+ }
+
+ if (FORBIDDEN_VALUES.includes(value.trim().toLowerCase())) {
+ throw new Error(
+ `FATAL: Environment variable ${name} has an insecure default value. ` +
+ `Please set a secure random value.`,
+ )
+ }
+
+ return value.trim()
+}
+
+/**
+ * Validiert alle erforderlichen Environment-Variablen.
+ * Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen.
+ */
+export function validateRequiredEnvVars(): RequiredEnvVars {
+ return {
+ PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET),
+ DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI),
+ CONSENT_LOGGING_API_KEY: validateEnvVar(
+ 'CONSENT_LOGGING_API_KEY',
+ process.env.CONSENT_LOGGING_API_KEY,
+ ),
+ IP_ANONYMIZATION_PEPPER: validateEnvVar(
+ 'IP_ANONYMIZATION_PEPPER',
+ process.env.IP_ANONYMIZATION_PEPPER,
+ ),
+ }
+}
+
+/**
+ * Lazy-initialized Environment-Variablen.
+ * Wird erst beim ersten Zugriff validiert (vermeidet Build-Probleme).
+ */
+let _cachedEnv: RequiredEnvVars | null = null
+
+export const env: RequiredEnvVars = new Proxy({} as RequiredEnvVars, {
+ get(_, prop: keyof RequiredEnvVars) {
+ if (!_cachedEnv) {
+ _cachedEnv = validateRequiredEnvVars()
+ }
+ return _cachedEnv[prop]
+ },
+})
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
new file mode 100644
index 0000000..74f5697
--- /dev/null
+++ b/src/lib/i18n.ts
@@ -0,0 +1,84 @@
+// src/lib/i18n.ts
+// Frontend i18n configuration and utilities
+
+export const locales = ['de', 'en'] as const
+export type Locale = (typeof locales)[number]
+export const defaultLocale: Locale = 'de'
+
+/**
+ * Validate if a string is a valid locale
+ */
+export function isValidLocale(locale: string): locale is Locale {
+ return locales.includes(locale as Locale)
+}
+
+/**
+ * Get locale from pathname
+ * Returns the locale if found, otherwise returns the default locale
+ */
+export function getLocaleFromPathname(pathname: string): Locale {
+ const segments = pathname.split('/').filter(Boolean)
+ const firstSegment = segments[0]
+
+ if (firstSegment && isValidLocale(firstSegment)) {
+ return firstSegment
+ }
+
+ return defaultLocale
+}
+
+/**
+ * Remove locale prefix from pathname
+ */
+export function removeLocaleFromPathname(pathname: string): string {
+ const locale = getLocaleFromPathname(pathname)
+ const segments = pathname.split('/').filter(Boolean)
+
+ if (segments[0] === locale) {
+ segments.shift()
+ }
+
+ return '/' + segments.join('/')
+}
+
+/**
+ * Add locale prefix to pathname
+ */
+export function addLocaleToPathname(pathname: string, locale: Locale): string {
+ const cleanPathname = removeLocaleFromPathname(pathname)
+
+ // For default locale, we could optionally skip the prefix
+ // But for consistency, we'll always include it
+ return `/${locale}${cleanPathname === '/' ? '' : cleanPathname}`
+}
+
+/**
+ * Get alternate language links for SEO (hreflang)
+ */
+export function getAlternateLinks(
+ pathname: string,
+ baseUrl: string,
+): { locale: Locale; url: string }[] {
+ const cleanPathname = removeLocaleFromPathname(pathname)
+
+ return locales.map((locale) => ({
+ locale,
+ url: `${baseUrl}/${locale}${cleanPathname === '/' ? '' : cleanPathname}`,
+ }))
+}
+
+/**
+ * Locale display names for UI
+ */
+export const localeNames: Record = {
+ de: { native: 'Deutsch', english: 'German' },
+ en: { native: 'English', english: 'English' },
+}
+
+/**
+ * Get locale direction (for RTL support in future)
+ */
+export function getLocaleDirection(locale: Locale): 'ltr' | 'rtl' {
+ // Both German and English are LTR
+ return 'ltr'
+}
diff --git a/src/lib/search.ts b/src/lib/search.ts
new file mode 100644
index 0000000..9305511
--- /dev/null
+++ b/src/lib/search.ts
@@ -0,0 +1,526 @@
+// src/lib/search.ts
+// Shared search library with caching, rate limiting, and search functions
+
+import type { Payload, Where } from 'payload'
+import type { Post, Category } from '../payload-types'
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface SearchParams {
+ query: string
+ tenantId?: number
+ categorySlug?: string
+ type?: 'blog' | 'news' | 'press' | 'announcement'
+ locale?: string
+ limit?: number
+ offset?: number
+}
+
+export interface SearchResult {
+ results: SearchResultItem[]
+ total: number
+ query: string
+ filters: {
+ category?: string
+ type?: string
+ locale?: string
+ }
+ pagination: {
+ limit: number
+ offset: number
+ hasMore: boolean
+ }
+}
+
+export interface SearchResultItem {
+ id: number
+ title: string
+ slug: string
+ excerpt: string | null
+ publishedAt: string | null
+ type: string
+ category: {
+ name: string
+ slug: string
+ } | null
+}
+
+export interface SuggestionParams {
+ query: string
+ tenantId?: number
+ categorySlug?: string
+ locale?: string
+ limit?: number
+}
+
+export interface Suggestion {
+ title: string
+ slug: string
+ type: string
+}
+
+export interface CategoryFilterParams {
+ tenantId?: number
+ categorySlug?: string
+ type?: 'blog' | 'news' | 'press' | 'announcement'
+ locale?: string
+ page?: number
+ limit?: number
+}
+
+export interface PaginatedPosts {
+ docs: Post[]
+ totalDocs: number
+ page: number
+ totalPages: number
+ hasNextPage: boolean
+ hasPrevPage: boolean
+}
+
+export interface RateLimitResult {
+ allowed: boolean
+ remaining: number
+ retryAfter?: number
+}
+
+// ============================================================================
+// TTL Cache Implementation
+// ============================================================================
+
+interface CacheEntry {
+ value: T
+ expiresAt: number
+}
+
+class TTLCache {
+ private cache = new Map>()
+ private readonly ttlMs: number
+ private cleanupInterval: ReturnType | null = null
+
+ constructor(ttlSeconds: number = 60) {
+ this.ttlMs = ttlSeconds * 1000
+ // Cleanup expired entries every minute
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60000)
+ }
+
+ get(key: K): V | undefined {
+ const entry = this.cache.get(key)
+ if (!entry) return undefined
+
+ if (Date.now() > entry.expiresAt) {
+ this.cache.delete(key)
+ return undefined
+ }
+
+ return entry.value
+ }
+
+ set(key: K, value: V): void {
+ this.cache.set(key, {
+ value,
+ expiresAt: Date.now() + this.ttlMs,
+ })
+ }
+
+ delete(key: K): boolean {
+ return this.cache.delete(key)
+ }
+
+ clear(): void {
+ this.cache.clear()
+ }
+
+ private cleanup(): void {
+ const now = Date.now()
+ for (const [key, entry] of this.cache.entries()) {
+ if (now > entry.expiresAt) {
+ this.cache.delete(key)
+ }
+ }
+ }
+
+ destroy(): void {
+ if (this.cleanupInterval) {
+ clearInterval(this.cleanupInterval)
+ this.cleanupInterval = null
+ }
+ this.cache.clear()
+ }
+}
+
+// Cache instances (60 second TTL)
+export const searchCache = new TTLCache(60)
+export const suggestionCache = new TTLCache(60)
+
+// ============================================================================
+// Rate Limiting
+// ============================================================================
+
+interface RateLimitEntry {
+ count: number
+ windowStart: number
+}
+
+const rateLimitStore = new Map()
+const RATE_LIMIT_WINDOW_MS = 60000 // 1 minute
+const RATE_LIMIT_MAX_REQUESTS = 30 // 30 requests per minute
+
+// Cleanup rate limit entries every 5 minutes
+setInterval(() => {
+ const now = Date.now()
+ for (const [key, entry] of rateLimitStore.entries()) {
+ if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
+ rateLimitStore.delete(key)
+ }
+ }
+}, 300000)
+
+export function checkRateLimit(ip: string): RateLimitResult {
+ const now = Date.now()
+ const entry = rateLimitStore.get(ip)
+
+ if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
+ // New window
+ rateLimitStore.set(ip, { count: 1, windowStart: now })
+ return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1 }
+ }
+
+ if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
+ const retryAfter = Math.ceil((entry.windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000)
+ return { allowed: false, remaining: 0, retryAfter }
+ }
+
+ entry.count++
+ return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - entry.count }
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+/**
+ * Extract plain text from Lexical JSONB content
+ */
+export function extractTextFromLexical(content: unknown): string {
+ if (!content || typeof content !== 'object') return ''
+
+ const extractFromNode = (node: unknown): string => {
+ if (!node || typeof node !== 'object') return ''
+
+ const n = node as Record
+ let text = ''
+
+ // Get text content
+ if (typeof n.text === 'string') {
+ text += n.text + ' '
+ }
+
+ // Recursively process children
+ if (Array.isArray(n.children)) {
+ for (const child of n.children) {
+ text += extractFromNode(child)
+ }
+ }
+
+ return text
+ }
+
+ const c = content as Record
+ if (c.root && typeof c.root === 'object') {
+ return extractFromNode(c.root).trim()
+ }
+
+ return ''
+}
+
+/**
+ * Build Payload where clause for search
+ */
+export function buildSearchWhere(
+ query: string,
+ filters: {
+ tenantId?: number
+ categorySlug?: string
+ type?: string
+ },
+): Where {
+ const conditions: Where[] = [
+ // Only published posts
+ { status: { equals: 'published' } },
+ ]
+
+ // Tenant filter
+ if (filters.tenantId) {
+ conditions.push({ tenant: { equals: filters.tenantId } })
+ }
+
+ // Category filter (requires lookup or slug field)
+ // Note: We'll handle this separately since category is a relationship
+
+ // Type filter
+ if (filters.type) {
+ conditions.push({ type: { equals: filters.type } })
+ }
+
+ // Search in title and excerpt using ILIKE (case-insensitive contains)
+ if (query) {
+ conditions.push({
+ or: [{ title: { contains: query } }, { excerpt: { contains: query } }],
+ })
+ }
+
+ return { and: conditions }
+}
+
+/**
+ * Generate cache key from search parameters
+ */
+function generateCacheKey(prefix: string, params: Record): string {
+ const sortedEntries = Object.entries(params)
+ .filter(([, v]) => v !== undefined && v !== null)
+ .sort(([a], [b]) => a.localeCompare(b))
+ return `${prefix}:${JSON.stringify(sortedEntries)}`
+}
+
+// ============================================================================
+// Search Functions
+// ============================================================================
+
+/**
+ * Search posts with ILIKE (Phase 1)
+ * Future: Add FTS support with USE_FTS=true
+ */
+export async function searchPosts(
+ payload: Payload,
+ params: SearchParams,
+): Promise {
+ const { query, tenantId, categorySlug, type, locale = 'de', limit = 10, offset = 0 } = params
+
+ // Check cache
+ const cacheKey = generateCacheKey('search', { query, tenantId, categorySlug, type, locale, limit, offset })
+ const cached = searchCache.get(cacheKey)
+ if (cached) {
+ return cached
+ }
+
+ // Build where clause
+ const whereConditions: Where[] = [{ status: { equals: 'published' } }]
+
+ if (tenantId) {
+ whereConditions.push({ tenant: { equals: tenantId } })
+ }
+
+ if (type) {
+ whereConditions.push({ type: { equals: type } })
+ }
+
+ // Search in title and excerpt
+ if (query && query.length >= 2) {
+ whereConditions.push({
+ or: [{ title: { contains: query } }, { excerpt: { contains: query } }],
+ })
+ }
+
+ // Category filter - need to lookup category first if categorySlug provided
+ let categoryId: number | undefined
+ if (categorySlug) {
+ const categoryResult = await payload.find({
+ collection: 'categories',
+ where: {
+ slug: { equals: categorySlug },
+ ...(tenantId ? { tenant: { equals: tenantId } } : {}),
+ },
+ locale: locale as 'de' | 'en',
+ fallbackLocale: 'de',
+ limit: 1,
+ })
+ if (categoryResult.docs.length > 0) {
+ categoryId = categoryResult.docs[0].id
+ whereConditions.push({ category: { equals: categoryId } })
+ }
+ }
+
+ const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0]
+
+ // Execute search
+ const result = await payload.find({
+ collection: 'posts',
+ where,
+ locale: locale as 'de' | 'en',
+ fallbackLocale: 'de',
+ limit,
+ page: Math.floor(offset / limit) + 1,
+ sort: '-publishedAt',
+ depth: 1, // Include category relation
+ })
+
+ // Transform results
+ const searchResult: SearchResult = {
+ results: result.docs.map((post) => ({
+ id: post.id,
+ title: post.title,
+ slug: post.slug,
+ excerpt: post.excerpt || null,
+ publishedAt: post.publishedAt || null,
+ type: (post as Post & { type?: string }).type || 'blog',
+ category:
+ post.category && typeof post.category === 'object'
+ ? { name: post.category.name, slug: post.category.slug }
+ : null,
+ })),
+ total: result.totalDocs,
+ query,
+ filters: {
+ category: categorySlug,
+ type,
+ locale,
+ },
+ pagination: {
+ limit,
+ offset,
+ hasMore: result.hasNextPage,
+ },
+ }
+
+ // Cache result
+ searchCache.set(cacheKey, searchResult)
+
+ return searchResult
+}
+
+/**
+ * Get search suggestions (auto-complete)
+ */
+export async function getSearchSuggestions(
+ payload: Payload,
+ params: SuggestionParams,
+): Promise {
+ const { query, tenantId, categorySlug, locale = 'de', limit = 5 } = params
+
+ if (!query || query.length < 2) {
+ return []
+ }
+
+ // Check cache
+ const cacheKey = generateCacheKey('suggestions', { query, tenantId, categorySlug, locale, limit })
+ const cached = suggestionCache.get(cacheKey)
+ if (cached) {
+ return cached
+ }
+
+ // Build where clause
+ const whereConditions: Where[] = [
+ { status: { equals: 'published' } },
+ // Prefix search on title
+ { title: { contains: query } },
+ ]
+
+ if (tenantId) {
+ whereConditions.push({ tenant: { equals: tenantId } })
+ }
+
+ // Category filter
+ if (categorySlug) {
+ const categoryResult = await payload.find({
+ collection: 'categories',
+ where: {
+ slug: { equals: categorySlug },
+ ...(tenantId ? { tenant: { equals: tenantId } } : {}),
+ },
+ locale: locale as 'de' | 'en',
+ fallbackLocale: 'de',
+ limit: 1,
+ })
+ if (categoryResult.docs.length > 0) {
+ whereConditions.push({ category: { equals: categoryResult.docs[0].id } })
+ }
+ }
+
+ const where: Where = { and: whereConditions }
+
+ // Execute query
+ const result = await payload.find({
+ collection: 'posts',
+ where,
+ locale: locale as 'de' | 'en',
+ fallbackLocale: 'de',
+ limit,
+ sort: '-publishedAt',
+ depth: 0,
+ })
+
+ // Transform to suggestions
+ const suggestions: Suggestion[] = result.docs.map((post) => ({
+ title: post.title,
+ slug: post.slug,
+ type: (post as Post & { type?: string }).type || 'blog',
+ }))
+
+ // Cache result
+ suggestionCache.set(cacheKey, suggestions)
+
+ return suggestions
+}
+
+/**
+ * Get posts by category with pagination
+ */
+export async function getPostsByCategory(
+ payload: Payload,
+ params: CategoryFilterParams,
+): Promise {
+ const { tenantId, categorySlug, type, locale = 'de', page = 1, limit = 10 } = params
+
+ // Build where clause
+ const whereConditions: Where[] = [{ status: { equals: 'published' } }]
+
+ if (tenantId) {
+ whereConditions.push({ tenant: { equals: tenantId } })
+ }
+
+ if (type) {
+ whereConditions.push({ type: { equals: type } })
+ }
+
+ // Category filter
+ if (categorySlug) {
+ const categoryResult = await payload.find({
+ collection: 'categories',
+ where: {
+ slug: { equals: categorySlug },
+ ...(tenantId ? { tenant: { equals: tenantId } } : {}),
+ },
+ locale: locale as 'de' | 'en',
+ fallbackLocale: 'de',
+ limit: 1,
+ })
+ if (categoryResult.docs.length > 0) {
+ whereConditions.push({ category: { equals: categoryResult.docs[0].id } })
+ }
+ }
+
+ const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0]
+
+ // Execute query
+ const result = await payload.find({
+ collection: 'posts',
+ where,
+ locale: locale as 'de' | 'en',
+ fallbackLocale: 'de',
+ page,
+ limit,
+ sort: '-publishedAt',
+ depth: 1,
+ })
+
+ return {
+ docs: result.docs,
+ totalDocs: result.totalDocs,
+ page: result.page || page,
+ totalPages: result.totalPages,
+ hasNextPage: result.hasNextPage,
+ hasPrevPage: result.hasPrevPage,
+ }
+}
diff --git a/src/lib/structuredData.ts b/src/lib/structuredData.ts
new file mode 100644
index 0000000..2f49bdd
--- /dev/null
+++ b/src/lib/structuredData.ts
@@ -0,0 +1,480 @@
+/**
+ * Structured Data (JSON-LD) Helpers
+ *
+ * Diese Funktionen generieren Schema.org-konforme JSON-LD Daten
+ * für verschiedene Content-Typen.
+ *
+ * Verwendung in Next.js:
+ * ```tsx
+ * import { generateArticleSchema } from '@/lib/structuredData'
+ *
+ * export default function BlogPost({ post }) {
+ * return (
+ * <>
+ *
+ * ...
+ * >
+ * )
+ * }
+ * ```
+ */
+
+export interface OrganizationData {
+ name: string
+ url: string
+ logo?: string
+ description?: string
+ email?: string
+ phone?: string
+ address?: {
+ street?: string
+ city?: string
+ postalCode?: string
+ country?: string
+ }
+ socialProfiles?: string[]
+}
+
+export interface ArticleData {
+ title: string
+ description?: string
+ slug: string
+ publishedAt?: string
+ updatedAt?: string
+ author?: string
+ featuredImage?: {
+ url?: string
+ alt?: string
+ width?: number
+ height?: number
+ }
+ categories?: string[]
+}
+
+export interface WebPageData {
+ title: string
+ description?: string
+ url: string
+ updatedAt?: string
+ image?: {
+ url?: string
+ alt?: string
+ }
+}
+
+export interface BreadcrumbItem {
+ name: string
+ url: string
+}
+
+export interface FAQItem {
+ question: string
+ answer: string
+}
+
+export interface TestimonialData {
+ author: string
+ quote: string
+ rating?: number
+ company?: string
+ date?: string
+}
+
+/**
+ * Generiert Organization Schema
+ */
+export function generateOrganizationSchema(data: OrganizationData) {
+ const schema: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ name: data.name,
+ url: data.url,
+ }
+
+ if (data.logo) {
+ schema.logo = {
+ '@type': 'ImageObject',
+ url: data.logo,
+ }
+ }
+
+ if (data.description) {
+ schema.description = data.description
+ }
+
+ if (data.email) {
+ schema.email = data.email
+ }
+
+ if (data.phone) {
+ schema.telephone = data.phone
+ }
+
+ if (data.address) {
+ schema.address = {
+ '@type': 'PostalAddress',
+ streetAddress: data.address.street,
+ addressLocality: data.address.city,
+ postalCode: data.address.postalCode,
+ addressCountry: data.address.country || 'DE',
+ }
+ }
+
+ if (data.socialProfiles && data.socialProfiles.length > 0) {
+ schema.sameAs = data.socialProfiles
+ }
+
+ return schema
+}
+
+/**
+ * Generiert Article Schema für Blog-Posts und News
+ */
+export function generateArticleSchema(
+ data: ArticleData,
+ baseUrl: string,
+ organization?: OrganizationData
+) {
+ const articleUrl = `${baseUrl}/blog/${data.slug}`
+
+ const schema: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+ headline: data.title,
+ url: articleUrl,
+ mainEntityOfPage: {
+ '@type': 'WebPage',
+ '@id': articleUrl,
+ },
+ }
+
+ if (data.description) {
+ schema.description = data.description
+ }
+
+ if (data.publishedAt) {
+ schema.datePublished = data.publishedAt
+ }
+
+ if (data.updatedAt) {
+ schema.dateModified = data.updatedAt
+ }
+
+ if (data.author) {
+ schema.author = {
+ '@type': 'Person',
+ name: data.author,
+ }
+ } else if (organization) {
+ schema.author = {
+ '@type': 'Organization',
+ name: organization.name,
+ }
+ }
+
+ if (organization) {
+ schema.publisher = {
+ '@type': 'Organization',
+ name: organization.name,
+ url: organization.url,
+ ...(organization.logo && {
+ logo: {
+ '@type': 'ImageObject',
+ url: organization.logo,
+ },
+ }),
+ }
+ }
+
+ if (data.featuredImage?.url) {
+ schema.image = {
+ '@type': 'ImageObject',
+ url: data.featuredImage.url,
+ ...(data.featuredImage.alt && { caption: data.featuredImage.alt }),
+ ...(data.featuredImage.width && { width: data.featuredImage.width }),
+ ...(data.featuredImage.height && { height: data.featuredImage.height }),
+ }
+ }
+
+ if (data.categories && data.categories.length > 0) {
+ schema.keywords = data.categories.join(', ')
+ }
+
+ return schema
+}
+
+/**
+ * Generiert NewsArticle Schema für News und Pressemitteilungen
+ */
+export function generateNewsArticleSchema(
+ data: ArticleData,
+ baseUrl: string,
+ organization?: OrganizationData
+) {
+ const baseSchema = generateArticleSchema(data, baseUrl, organization)
+ return {
+ ...baseSchema,
+ '@type': 'NewsArticle',
+ }
+}
+
+/**
+ * Generiert WebPage Schema
+ */
+export function generateWebPageSchema(data: WebPageData, organization?: OrganizationData) {
+ const schema: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'WebPage',
+ name: data.title,
+ url: data.url,
+ }
+
+ if (data.description) {
+ schema.description = data.description
+ }
+
+ if (data.updatedAt) {
+ schema.dateModified = data.updatedAt
+ }
+
+ if (data.image?.url) {
+ schema.image = {
+ '@type': 'ImageObject',
+ url: data.image.url,
+ ...(data.image.alt && { caption: data.image.alt }),
+ }
+ }
+
+ if (organization) {
+ schema.publisher = {
+ '@type': 'Organization',
+ name: organization.name,
+ }
+ }
+
+ return schema
+}
+
+/**
+ * Generiert BreadcrumbList Schema
+ */
+export function generateBreadcrumbSchema(items: BreadcrumbItem[]) {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: items.map((item, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.name,
+ item: item.url,
+ })),
+ }
+}
+
+/**
+ * Generiert FAQPage Schema
+ */
+export function generateFAQSchema(items: FAQItem[]) {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'FAQPage',
+ mainEntity: items.map((item) => ({
+ '@type': 'Question',
+ name: item.question,
+ acceptedAnswer: {
+ '@type': 'Answer',
+ text: item.answer,
+ },
+ })),
+ }
+}
+
+/**
+ * Generiert Review/Testimonial Schema
+ */
+export function generateReviewSchema(
+ testimonial: TestimonialData,
+ organization: OrganizationData
+) {
+ const schema: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'Review',
+ reviewBody: testimonial.quote,
+ author: {
+ '@type': 'Person',
+ name: testimonial.author,
+ },
+ itemReviewed: {
+ '@type': 'Organization',
+ name: organization.name,
+ },
+ }
+
+ if (testimonial.rating) {
+ schema.reviewRating = {
+ '@type': 'Rating',
+ ratingValue: testimonial.rating,
+ bestRating: 5,
+ worstRating: 1,
+ }
+ }
+
+ if (testimonial.date) {
+ schema.datePublished = testimonial.date
+ }
+
+ return schema
+}
+
+/**
+ * Generiert AggregateRating Schema für Testimonials
+ */
+export function generateAggregateRatingSchema(
+ testimonials: TestimonialData[],
+ organization: OrganizationData
+) {
+ const ratingsWithScore = testimonials.filter((t) => t.rating)
+ if (ratingsWithScore.length === 0) return null
+
+ const totalRating = ratingsWithScore.reduce((sum, t) => sum + (t.rating || 0), 0)
+ const averageRating = totalRating / ratingsWithScore.length
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ name: organization.name,
+ url: organization.url,
+ aggregateRating: {
+ '@type': 'AggregateRating',
+ ratingValue: averageRating.toFixed(1),
+ bestRating: 5,
+ worstRating: 1,
+ ratingCount: ratingsWithScore.length,
+ },
+ }
+}
+
+/**
+ * Generiert LocalBusiness Schema
+ */
+export function generateLocalBusinessSchema(
+ data: OrganizationData & {
+ type?: string
+ openingHours?: string[]
+ priceRange?: string
+ geo?: {
+ latitude: number
+ longitude: number
+ }
+ }
+) {
+ const schema: Record = {
+ '@context': 'https://schema.org',
+ '@type': data.type || 'LocalBusiness',
+ name: data.name,
+ url: data.url,
+ }
+
+ if (data.logo) {
+ schema.image = data.logo
+ }
+
+ if (data.description) {
+ schema.description = data.description
+ }
+
+ if (data.email) {
+ schema.email = data.email
+ }
+
+ if (data.phone) {
+ schema.telephone = data.phone
+ }
+
+ if (data.address) {
+ schema.address = {
+ '@type': 'PostalAddress',
+ streetAddress: data.address.street,
+ addressLocality: data.address.city,
+ postalCode: data.address.postalCode,
+ addressCountry: data.address.country || 'DE',
+ }
+ }
+
+ if (data.openingHours && data.openingHours.length > 0) {
+ schema.openingHours = data.openingHours
+ }
+
+ if (data.priceRange) {
+ schema.priceRange = data.priceRange
+ }
+
+ if (data.geo) {
+ schema.geo = {
+ '@type': 'GeoCoordinates',
+ latitude: data.geo.latitude,
+ longitude: data.geo.longitude,
+ }
+ }
+
+ if (data.socialProfiles && data.socialProfiles.length > 0) {
+ schema.sameAs = data.socialProfiles
+ }
+
+ return schema
+}
+
+/**
+ * Generiert WebSite Schema mit SearchAction
+ */
+export function generateWebSiteSchema(
+ name: string,
+ url: string,
+ searchUrl?: string
+) {
+ const schema: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'WebSite',
+ name,
+ url,
+ }
+
+ if (searchUrl) {
+ schema.potentialAction = {
+ '@type': 'SearchAction',
+ target: {
+ '@type': 'EntryPoint',
+ urlTemplate: `${searchUrl}?q={search_term_string}`,
+ },
+ 'query-input': 'required name=search_term_string',
+ }
+ }
+
+ return schema
+}
+
+/**
+ * Kombiniert mehrere Schemas in ein Array
+ */
+export function combineSchemas(...schemas: (Record | null)[]) {
+ const validSchemas = schemas.filter((s) => s !== null)
+
+ if (validSchemas.length === 0) return null
+ if (validSchemas.length === 1) return validSchemas[0]
+
+ return validSchemas
+}
+
+/**
+ * Hilfsfunktion zum sicheren Rendern von JSON-LD
+ */
+export function renderJsonLd(schema: Record | Record[] | null) {
+ if (!schema) return null
+
+ return JSON.stringify(schema, null, process.env.NODE_ENV === 'development' ? 2 : 0)
+}
diff --git a/src/lib/tenantAccess.ts b/src/lib/tenantAccess.ts
new file mode 100644
index 0000000..56bf48d
--- /dev/null
+++ b/src/lib/tenantAccess.ts
@@ -0,0 +1,83 @@
+// src/lib/tenantAccess.ts
+
+import type { Access, PayloadRequest } from 'payload'
+
+/**
+ * Ermittelt die Tenant-ID aus dem Request-Host.
+ * Gleicht die Domain mit der tenants-Collection ab.
+ */
+export async function getTenantIdFromHost(req: PayloadRequest): Promise {
+ try {
+ // Host-Header extrahieren (unterstützt verschiedene Formate)
+ const headers = req.headers as Headers | Record
+ const host =
+ typeof headers.get === 'function'
+ ? headers.get('host')
+ : (headers as Record)['host']
+
+ if (!host || typeof host !== 'string') {
+ return null
+ }
+
+ // Domain normalisieren: Port und www entfernen
+ const domain = host.split(':')[0].replace(/^www\./, '').toLowerCase().trim()
+
+ if (!domain) {
+ return null
+ }
+
+ // Tenant aus Datenbank suchen (domains ist ein Array mit domain-Subfeld)
+ const result = await req.payload.find({
+ collection: 'tenants',
+ where: {
+ 'domains.domain': { equals: domain },
+ },
+ limit: 1,
+ depth: 0,
+ })
+
+ if (result.docs.length > 0 && result.docs[0]?.id) {
+ return Number(result.docs[0].id)
+ }
+
+ return null
+ } catch (error) {
+ console.error('[TenantAccess] Error resolving tenant from host:', error)
+ return null
+ }
+}
+
+/**
+ * Access-Control für öffentlich lesbare, aber tenant-isolierte Collections.
+ *
+ * - Authentifizierte Admin-User: Voller Lesezugriff
+ * - Anonyme Requests: Nur Daten des eigenen Tenants (basierend auf Domain)
+ */
+export const tenantScopedPublicRead: Access = async ({ req }) => {
+ // Authentifizierte Admins dürfen alles lesen
+ if (req.user) {
+ return true
+ }
+
+ // Anonyme Requests: Tenant aus Domain ermitteln
+ const tenantId = await getTenantIdFromHost(req)
+
+ if (!tenantId) {
+ // Keine gültige Domain → kein Zugriff
+ return false
+ }
+
+ // Nur Dokumente des eigenen Tenants zurückgeben
+ return {
+ tenant: {
+ equals: tenantId,
+ },
+ }
+}
+
+/**
+ * Access-Control: Nur authentifizierte User
+ */
+export const authenticatedOnly: Access = ({ req }) => {
+ return !!req.user
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..b0df452
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,101 @@
+// src/middleware.ts
+// Next.js Middleware for locale detection and routing
+
+import { NextRequest, NextResponse } from 'next/server'
+import { locales, defaultLocale, isValidLocale, type Locale } from '@/lib/i18n'
+
+// Paths that should not be affected by locale routing
+const PUBLIC_FILE = /\.(.*)$/
+const EXCLUDED_PATHS = ['/admin', '/api', '/_next', '/favicon.ico', '/robots.txt', '/sitemap.xml']
+
+/**
+ * Detect user's preferred locale from Accept-Language header
+ */
+function getPreferredLocale(request: NextRequest): Locale {
+ const acceptLanguage = request.headers.get('accept-language')
+
+ if (!acceptLanguage) {
+ return defaultLocale
+ }
+
+ // Parse Accept-Language header
+ const languages = acceptLanguage
+ .split(',')
+ .map((lang) => {
+ const [code, priority] = lang.trim().split(';q=')
+ return {
+ code: code.split('-')[0].toLowerCase(), // Get primary language code
+ priority: priority ? parseFloat(priority) : 1,
+ }
+ })
+ .sort((a, b) => b.priority - a.priority)
+
+ // Find first matching locale
+ for (const { code } of languages) {
+ if (isValidLocale(code)) {
+ return code
+ }
+ }
+
+ return defaultLocale
+}
+
+/**
+ * Get locale from cookie
+ */
+function getLocaleFromCookie(request: NextRequest): Locale | null {
+ const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value
+
+ if (cookieLocale && isValidLocale(cookieLocale)) {
+ return cookieLocale
+ }
+
+ return null
+}
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl
+
+ // Skip locale routing for excluded paths and public files
+ if (
+ EXCLUDED_PATHS.some((path) => pathname.startsWith(path)) ||
+ PUBLIC_FILE.test(pathname)
+ ) {
+ return NextResponse.next()
+ }
+
+ // Check if pathname already has a valid locale prefix
+ const pathnameLocale = pathname.split('/')[1]
+
+ if (isValidLocale(pathnameLocale)) {
+ // Valid locale in URL, set cookie and continue
+ const response = NextResponse.next()
+ response.cookies.set('NEXT_LOCALE', pathnameLocale, {
+ maxAge: 60 * 60 * 24 * 365, // 1 year
+ path: '/',
+ })
+ return response
+ }
+
+ // No locale in URL, redirect to preferred locale
+ const cookieLocale = getLocaleFromCookie(request)
+ const preferredLocale = cookieLocale || getPreferredLocale(request)
+
+ // Build new URL with locale prefix
+ const newUrl = new URL(request.url)
+ newUrl.pathname = `/${preferredLocale}${pathname === '/' ? '' : pathname}`
+
+ // Redirect to localized URL
+ const response = NextResponse.redirect(newUrl)
+ response.cookies.set('NEXT_LOCALE', preferredLocale, {
+ maxAge: 60 * 60 * 24 * 365, // 1 year
+ path: '/',
+ })
+
+ return response
+}
+
+export const config = {
+ // Match all paths except static files and API routes
+ matcher: ['/((?!api|_next/static|_next/image|admin|favicon.ico).*)'],
+}