mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
feat: add i18n, SEO, and frontend infrastructure
Localization: - Add middleware for locale detection/routing - Add [locale] dynamic route structure - Add i18n utility library (DE/EN support) SEO & Discovery: - Add robots.ts for search engine directives - Add sitemap.ts for XML sitemap generation - Add structuredData.ts for JSON-LD schemas Utilities: - Add search.ts for full-text search functionality - Add tenantAccess.ts for multi-tenant access control - Add envValidation.ts for environment validation Frontend: - Update layout.tsx with locale support - Update page.tsx for localized content - Add API routes for frontend functionality - Add instrumentation.ts for monitoring 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
95c9d2a4bc
commit
51c340e9e7
16 changed files with 2006 additions and 66 deletions
30
src/app/(frontend)/[locale]/layout.tsx
Normal file
30
src/app/(frontend)/[locale]/layout.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<html lang={locale} dir={direction}>
|
||||||
|
<body>
|
||||||
|
<main>{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/app/(frontend)/[locale]/page.tsx
Normal file
100
src/app/(frontend)/[locale]/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="home">
|
||||||
|
<div className="content">
|
||||||
|
<picture>
|
||||||
|
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" />
|
||||||
|
<Image
|
||||||
|
alt="Payload Logo"
|
||||||
|
height={65}
|
||||||
|
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg"
|
||||||
|
width={65}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
{!user && <h1>{t.welcome}</h1>}
|
||||||
|
{user && (
|
||||||
|
<h1>
|
||||||
|
{t.welcomeBack} {user.email}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
<div className="links">
|
||||||
|
<a
|
||||||
|
className="admin"
|
||||||
|
href={payloadConfig.routes.admin}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t.adminPanel}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="docs"
|
||||||
|
href="https://payloadcms.com/docs"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t.documentation}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="locale-info" style={{ marginTop: '1rem' }}>
|
||||||
|
<p>
|
||||||
|
Current locale: <strong>{localeNames[locale as Locale].native}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="footer">
|
||||||
|
<p>{t.updatePage}</p>
|
||||||
|
<a className="codeLink" href={fileURL}>
|
||||||
|
<code>app/(frontend)/[locale]/page.tsx</code>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
src/app/(frontend)/api/posts/route.ts
Normal file
136
src/app/(frontend)/api/posts/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/app/(frontend)/api/search/route.ts
Normal file
118
src/app/(frontend)/api/search/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/app/(frontend)/api/search/suggestions/route.ts
Normal file
112
src/app/(frontend)/api/search/suggestions/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,18 +2,15 @@ import React from 'react'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
description: 'A blank template using Payload in a Next.js app.',
|
description: 'Multi-Tenant CMS für mehrere Websites',
|
||||||
title: 'Payload Blank Template',
|
title: 'Payload CMS',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
const { children } = props
|
const { children } = props
|
||||||
|
|
||||||
return (
|
// This layout wraps both localized and non-localized routes
|
||||||
<html lang="en">
|
// The [locale]/layout.tsx handles the html/body for localized routes
|
||||||
<body>
|
// For API routes etc, this provides basic wrapper
|
||||||
<main>{children}</main>
|
return <>{children}</>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,8 @@
|
||||||
import { headers as getHeaders } from 'next/headers.js'
|
import { redirect } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
import { defaultLocale } from '@/lib/i18n'
|
||||||
import { getPayload } from 'payload'
|
|
||||||
import React from 'react'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
|
|
||||||
import config from '@/payload.config'
|
// This page catches requests to / and redirects to the default locale
|
||||||
import './styles.css'
|
// The middleware should normally handle this, but this is a fallback
|
||||||
|
export default function RootPage() {
|
||||||
export default async function HomePage() {
|
redirect(`/${defaultLocale}`)
|
||||||
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 (
|
|
||||||
<div className="home">
|
|
||||||
<div className="content">
|
|
||||||
<picture>
|
|
||||||
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg" />
|
|
||||||
<Image
|
|
||||||
alt="Payload Logo"
|
|
||||||
height={65}
|
|
||||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-favicon.svg"
|
|
||||||
width={65}
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
{!user && <h1>Welcome to your new project.</h1>}
|
|
||||||
{user && <h1>Welcome back, {user.email}</h1>}
|
|
||||||
<div className="links">
|
|
||||||
<a
|
|
||||||
className="admin"
|
|
||||||
href={payloadConfig.routes.admin}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Go to admin panel
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="docs"
|
|
||||||
href="https://payloadcms.com/docs"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="footer">
|
|
||||||
<p>Update this page by editing</p>
|
|
||||||
<a className="codeLink" href={fileURL}>
|
|
||||||
<code>app/(frontend)/page.tsx</code>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
src/app/robots.ts
Normal file
40
src/app/robots.ts
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/app/sitemap.ts
Normal file
87
src/app/sitemap.ts
Normal file
|
|
@ -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<MetadataRoute.Sitemap> {
|
||||||
|
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
|
||||||
|
}
|
||||||
24
src/instrumentation.ts
Normal file
24
src/instrumentation.ts
Normal file
|
|
@ -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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/lib/envValidation.ts
Normal file
73
src/lib/envValidation.ts
Normal file
|
|
@ -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]
|
||||||
|
},
|
||||||
|
})
|
||||||
84
src/lib/i18n.ts
Normal file
84
src/lib/i18n.ts
Normal file
|
|
@ -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<Locale, { native: string; english: string }> = {
|
||||||
|
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'
|
||||||
|
}
|
||||||
526
src/lib/search.ts
Normal file
526
src/lib/search.ts
Normal file
|
|
@ -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<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class TTLCache<K, V> {
|
||||||
|
private cache = new Map<K, CacheEntry<V>>()
|
||||||
|
private readonly ttlMs: number
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | 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<string, SearchResult>(60)
|
||||||
|
export const suggestionCache = new TTLCache<string, Suggestion[]>(60)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rate Limiting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number
|
||||||
|
windowStart: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitStore = new Map<string, RateLimitEntry>()
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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, unknown>): 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<SearchResult> {
|
||||||
|
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<Suggestion[]> {
|
||||||
|
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<PaginatedPosts> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
480
src/lib/structuredData.ts
Normal file
480
src/lib/structuredData.ts
Normal file
|
|
@ -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 (
|
||||||
|
* <>
|
||||||
|
* <script
|
||||||
|
* type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{
|
||||||
|
* __html: JSON.stringify(generateArticleSchema(post))
|
||||||
|
* }}
|
||||||
|
* />
|
||||||
|
* <article>...</article>
|
||||||
|
* </>
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<string, unknown> = {
|
||||||
|
'@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<string, unknown> = {
|
||||||
|
'@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<string, unknown> = {
|
||||||
|
'@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<string, unknown> = {
|
||||||
|
'@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<string, unknown> = {
|
||||||
|
'@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<string, unknown> = {
|
||||||
|
'@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<string, unknown> | 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<string, unknown> | Record<string, unknown>[] | null) {
|
||||||
|
if (!schema) return null
|
||||||
|
|
||||||
|
return JSON.stringify(schema, null, process.env.NODE_ENV === 'development' ? 2 : 0)
|
||||||
|
}
|
||||||
83
src/lib/tenantAccess.ts
Normal file
83
src/lib/tenantAccess.ts
Normal file
|
|
@ -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<number | null> {
|
||||||
|
try {
|
||||||
|
// Host-Header extrahieren (unterstützt verschiedene Formate)
|
||||||
|
const headers = req.headers as Headers | Record<string, string | string[] | undefined>
|
||||||
|
const host =
|
||||||
|
typeof headers.get === 'function'
|
||||||
|
? headers.get('host')
|
||||||
|
: (headers as Record<string, string | string[] | undefined>)['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
|
||||||
|
}
|
||||||
101
src/middleware.ts
Normal file
101
src/middleware.ts
Normal file
|
|
@ -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).*)'],
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue