feat: add content collections for multi-tenant CMS

New collections:
- Categories: hierarchical content categorization
- Pages: flexible page builder with blocks
- Posts: blog/news articles with SEO
- Testimonials: customer reviews/quotes

Cookie & Consent management:
- ConsentLogs: GDPR consent tracking
- CookieConfigurations: per-tenant cookie settings
- CookieInventory: cookie registry

Additional:
- NewsletterSubscribers: email subscription management
- PrivacyPolicySettings: privacy policy configuration
- SocialLinks: social media links

Update Media collection with tenant support and image variants

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-01 08:18:58 +00:00
parent 82c89f1494
commit 885ec93748
11 changed files with 1601 additions and 1 deletions

View file

@ -0,0 +1,34 @@
import type { CollectionConfig } from 'payload'
export const Categories: CollectionConfig = {
slug: 'categories',
admin: {
useAsTitle: 'name',
},
access: {
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
localized: true,
unique: false, // Uniqueness per locale handled by index
},
{
name: 'description',
type: 'textarea',
localized: true,
},
],
}

View file

@ -0,0 +1,225 @@
// src/collections/ConsentLogs.ts
import type { CollectionConfig } from 'payload'
import crypto from 'crypto'
import { env } from '../lib/envValidation'
import { authenticatedOnly } from '../lib/tenantAccess'
/**
* Generiert einen täglichen, tenant-spezifischen Salt für IP-Anonymisierung.
* Verwendet den sicher validierten Pepper aus der Umgebung.
*/
function getDailySalt(tenantId: string): string {
const date = new Date().toISOString().split('T')[0] // YYYY-MM-DD
return crypto
.createHash('sha256')
.update(`${env.IP_ANONYMIZATION_PEPPER}-${tenantId}-${date}`)
.digest('hex')
}
/**
* Anonymisiert eine IP-Adresse mit HMAC-SHA256.
* Der Salt rotiert täglich und ist tenant-spezifisch.
*/
function anonymizeIp(ip: string, tenantId: string): string {
const salt = getDailySalt(tenantId)
return crypto.createHmac('sha256', salt).update(ip).digest('hex').substring(0, 32) // Gekürzt für Lesbarkeit
}
/**
* Extrahiert die Client-IP aus dem Request.
* Berücksichtigt Reverse-Proxy-Header.
*/
function extractClientIp(req: any): string {
// X-Forwarded-For kann mehrere IPs enthalten (Client, Proxies)
const forwarded = req.headers?.['x-forwarded-for']
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim()
}
if (Array.isArray(forwarded) && forwarded.length > 0) {
return String(forwarded[0]).trim()
}
// X-Real-IP (einzelne IP)
const realIp = req.headers?.['x-real-ip']
if (typeof realIp === 'string') {
return realIp.trim()
}
// Fallback: Socket Remote Address
return req.socket?.remoteAddress || req.ip || 'unknown'
}
/**
* ConsentLogs Collection - WORM Audit Trail
*
* Implementiert das Write-Once-Read-Many Prinzip für DSGVO-Nachweispflicht.
* Updates und Deletes sind auf API-Ebene deaktiviert.
*/
export const ConsentLogs: CollectionConfig = {
slug: 'consent-logs',
admin: {
useAsTitle: 'consentId',
group: 'Consent Management',
description: 'WORM Audit-Trail für Cookie-Einwilligungen (unveränderbar)',
defaultColumns: ['consentId', 'tenant', 'categories', 'revision', 'createdAt'],
},
// Performance: Keine Versionierung für Audit-Logs
versions: false,
access: {
/**
* CREATE: Nur mit gültigem API-Key.
* Beide Seiten (Header UND Env-Variable) müssen existieren und übereinstimmen.
*/
create: ({ req }) => {
const headers = req.headers as Headers | Record<string, string | string[] | undefined>
const apiKey =
typeof headers.get === 'function'
? headers.get('x-api-key')
: (headers as Record<string, string | string[] | undefined>)['x-api-key']
// Strikte Validierung: Header muss existieren und non-empty sein
if (!apiKey || typeof apiKey !== 'string') {
return false
}
const trimmedKey = apiKey.trim()
if (trimmedKey === '') {
return false
}
// Vergleich mit validiertem Environment-Wert
// (env.CONSENT_LOGGING_API_KEY ist garantiert non-empty durch envValidation)
return trimmedKey === env.CONSENT_LOGGING_API_KEY
},
/**
* READ: Nur authentifizierte Admin-User
*/
read: authenticatedOnly,
/**
* UPDATE: WORM - Niemals erlaubt
*/
update: () => false,
/**
* DELETE: WORM - Niemals über API erlaubt
* (Nur via Retention-Job mit direktem DB-Zugriff)
*/
delete: () => false,
},
hooks: {
beforeChange: [
({ data, req, operation }) => {
// Nur bei Neuanlage
if (operation !== 'create') {
return data
}
// 1. Server-generierte Consent-ID (Trust Boundary)
data.consentId = crypto.randomUUID()
// 2. IP anonymisieren
const rawIp = data.ip || extractClientIp(req)
const tenantId = typeof data.tenant === 'object' ? String(data.tenant.id) : String(data.tenant)
data.anonymizedIp = anonymizeIp(rawIp, tenantId)
// Rohe IP NIEMALS speichern
delete data.ip
// 3. Ablaufdatum setzen (3 Jahre Retention gemäß DSGVO)
const expiresAt = new Date()
expiresAt.setFullYear(expiresAt.getFullYear() + 3)
data.expiresAt = expiresAt.toISOString()
// 4. User Agent kürzen (Datensparsamkeit)
if (data.userAgent && typeof data.userAgent === 'string') {
data.userAgent = data.userAgent.substring(0, 500)
}
return data
},
],
},
fields: [
{
name: 'consentId',
type: 'text',
required: true,
unique: true,
admin: {
readOnly: true,
description: 'Server-generierte eindeutige ID',
},
},
{
name: 'clientRef',
type: 'text',
admin: {
readOnly: true,
description: 'Client-seitige Referenz (Cookie-UUID) für Traceability',
},
},
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'categories',
type: 'json',
required: true,
admin: {
readOnly: true,
description: 'Akzeptierte Kategorien zum Zeitpunkt der Einwilligung',
},
},
{
name: 'revision',
type: 'number',
required: true,
admin: {
readOnly: true,
description: 'Version der Konfiguration zum Zeitpunkt der Zustimmung',
},
},
{
name: 'userAgent',
type: 'text',
admin: {
readOnly: true,
description: 'Browser/Device (für Forensik und Bot-Erkennung)',
},
},
{
name: 'anonymizedIp',
type: 'text',
admin: {
readOnly: true,
description: 'HMAC-Hash der IP (täglich rotierender, tenant-spezifischer Salt)',
},
},
{
name: 'expiresAt',
type: 'date',
required: true,
admin: {
readOnly: true,
description: 'Automatische Löschung nach 3 Jahren',
date: {
pickerAppearance: 'dayOnly',
},
},
},
],
}

View file

@ -0,0 +1,204 @@
// src/collections/CookieConfigurations.ts
import type { CollectionConfig } from 'payload'
import { tenantScopedPublicRead, authenticatedOnly } from '../lib/tenantAccess'
/**
* CookieConfigurations Collection
*
* Mandantenspezifische Cookie-Banner-Konfiguration.
* Öffentlich lesbar, aber nur für den eigenen Tenant (Domain-basiert).
*/
export const CookieConfigurations: CollectionConfig = {
slug: 'cookie-configurations',
admin: {
useAsTitle: 'title',
group: 'Consent Management',
description: 'Cookie-Banner Konfiguration pro Tenant',
},
access: {
// Öffentlich, aber tenant-isoliert (Domain-Check)
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
unique: true,
admin: {
description: 'Jeder Tenant kann nur eine Konfiguration haben',
},
},
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Cookie-Einstellungen',
admin: {
description: 'Interner Titel zur Identifikation',
},
},
{
name: 'revision',
type: 'number',
required: true,
defaultValue: 1,
admin: {
description:
'Bei inhaltlichen Änderungen erhöhen → erzwingt erneuten Consent bei allen Nutzern',
},
},
{
name: 'enabledCategories',
type: 'select',
hasMany: true,
required: true,
defaultValue: ['necessary', 'analytics'],
options: [
{ label: 'Notwendig', value: 'necessary' },
{ label: 'Funktional', value: 'functional' },
{ label: 'Statistik', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
],
admin: {
description: 'Welche Kategorien sollen im Banner angezeigt werden?',
},
},
{
name: 'translations',
type: 'group',
fields: [
{
name: 'de',
type: 'group',
label: 'Deutsch',
fields: [
{
name: 'bannerTitle',
type: 'text',
defaultValue: 'Wir respektieren Ihre Privatsphäre',
},
{
name: 'bannerDescription',
type: 'textarea',
defaultValue:
'Diese Website verwendet Cookies, um Ihnen die bestmögliche Erfahrung zu bieten.',
},
{
name: 'acceptAllButton',
type: 'text',
defaultValue: 'Alle akzeptieren',
},
{
name: 'acceptNecessaryButton',
type: 'text',
defaultValue: 'Nur notwendige',
},
{
name: 'settingsButton',
type: 'text',
defaultValue: 'Einstellungen',
},
{
name: 'saveButton',
type: 'text',
defaultValue: 'Auswahl speichern',
},
{
name: 'privacyPolicyUrl',
type: 'text',
defaultValue: '/datenschutz',
},
{
name: 'categoryLabels',
type: 'group',
fields: [
{
name: 'necessary',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Notwendig' },
{
name: 'description',
type: 'textarea',
defaultValue:
'Diese Cookies sind für die Grundfunktionen der Website erforderlich.',
},
],
},
{
name: 'functional',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Funktional' },
{
name: 'description',
type: 'textarea',
defaultValue: 'Diese Cookies ermöglichen erweiterte Funktionen.',
},
],
},
{
name: 'analytics',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Statistik' },
{
name: 'description',
type: 'textarea',
defaultValue:
'Diese Cookies helfen uns zu verstehen, wie Besucher die Website nutzen.',
},
],
},
{
name: 'marketing',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Marketing' },
{
name: 'description',
type: 'textarea',
defaultValue: 'Diese Cookies werden für Werbezwecke verwendet.',
},
],
},
],
},
],
},
],
},
{
name: 'styling',
type: 'group',
fields: [
{
name: 'position',
type: 'select',
defaultValue: 'bottom',
options: [
{ label: 'Unten', value: 'bottom' },
{ label: 'Oben', value: 'top' },
{ label: 'Mitte (Modal)', value: 'middle' },
],
},
{
name: 'theme',
type: 'select',
defaultValue: 'dark',
options: [
{ label: 'Dunkel', value: 'dark' },
{ label: 'Hell', value: 'light' },
{ label: 'Auto (System)', value: 'auto' },
],
},
],
},
],
}

View file

@ -0,0 +1,83 @@
// src/collections/CookieInventory.ts
import type { CollectionConfig } from 'payload'
import { tenantScopedPublicRead, authenticatedOnly } from '../lib/tenantAccess'
/**
* CookieInventory Collection
*
* Dokumentation aller verwendeten Cookies für die Datenschutzerklärung.
* Öffentlich lesbar, aber nur für den eigenen Tenant (Domain-basiert).
*/
export const CookieInventory: CollectionConfig = {
slug: 'cookie-inventory',
admin: {
useAsTitle: 'name',
group: 'Consent Management',
description: 'Cookie-Dokumentation für die Datenschutzerklärung',
defaultColumns: ['name', 'provider', 'category', 'duration', 'tenant'],
},
access: {
// Öffentlich, aber tenant-isoliert (Domain-Check)
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
},
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Technischer Name des Cookies (z.B. "_ga")',
},
},
{
name: 'provider',
type: 'text',
required: true,
admin: {
description: 'Anbieter (z.B. "Google LLC")',
},
},
{
name: 'category',
type: 'select',
required: true,
options: [
{ label: 'Notwendig', value: 'necessary' },
{ label: 'Funktional', value: 'functional' },
{ label: 'Statistik', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
],
},
{
name: 'duration',
type: 'text',
required: true,
admin: {
description: 'Speicherdauer (z.B. "2 Jahre")',
},
},
{
name: 'description',
type: 'textarea',
required: true,
admin: {
description: 'Verständliche Erklärung für Endnutzer',
},
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
},
],
}

View file

@ -2,15 +2,200 @@ import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
admin: {
useAsTitle: 'alt',
group: 'Medien',
description: 'Bilder und Dokumente mit automatischer Optimierung',
},
access: {
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
upload: {
staticDir: 'media',
mimeTypes: ['image/*', 'application/pdf', 'video/*'],
// Bildoptimierung mit Sharp
imageSizes: [
// Thumbnail für Admin-Übersichten und kleine Vorschauen
{
name: 'thumbnail',
width: 150,
height: 150,
fit: 'cover',
position: 'center',
formatOptions: {
format: 'webp',
options: {
quality: 80,
},
},
},
// Kleine Bilder für Cards, Avatare, Icons
{
name: 'small',
width: 400,
height: undefined,
fit: 'inside',
withoutEnlargement: true,
formatOptions: {
format: 'webp',
options: {
quality: 80,
},
},
},
// Mittlere Bilder für Blog-Vorschauen, Testimonials
{
name: 'medium',
width: 800,
height: undefined,
fit: 'inside',
withoutEnlargement: true,
formatOptions: {
format: 'webp',
options: {
quality: 82,
},
},
},
// Große Bilder für Hero-Sections, Vollbild-Ansichten
{
name: 'large',
width: 1200,
height: undefined,
fit: 'inside',
withoutEnlargement: true,
formatOptions: {
format: 'webp',
options: {
quality: 85,
},
},
},
// Extra große Bilder für hochauflösende Displays
{
name: 'xlarge',
width: 1920,
height: undefined,
fit: 'inside',
withoutEnlargement: true,
formatOptions: {
format: 'webp',
options: {
quality: 85,
},
},
},
// 2K für Retina/HiDPI Displays
{
name: '2k',
width: 2560,
height: undefined,
fit: 'inside',
withoutEnlargement: true,
formatOptions: {
format: 'webp',
options: {
quality: 85,
},
},
},
// Quadratisches Format für Social Media / OG Images
{
name: 'og',
width: 1200,
height: 630,
fit: 'cover',
position: 'center',
formatOptions: {
format: 'webp',
options: {
quality: 85,
},
},
},
// AVIF-Varianten für moderne Browser (beste Kompression)
{
name: 'medium_avif',
width: 800,
height: undefined,
fit: 'inside',
withoutEnlargement: true,
formatOptions: {
format: 'avif',
options: {
quality: 70,
},
},
},
{
name: 'large_avif',
width: 1200,
height: undefined,
fit: 'inside',
withoutEnlargement: true,
formatOptions: {
format: 'avif',
options: {
quality: 70,
},
},
},
{
name: 'xlarge_avif',
width: 1920,
height: undefined,
fit: 'inside',
withoutEnlargement: true,
formatOptions: {
format: 'avif',
options: {
quality: 70,
},
},
},
],
// Admin-Thumbnail für die Übersicht
adminThumbnail: 'thumbnail',
// Fokuspunkt für Cropping
focalPoint: true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
label: 'Alt-Text',
admin: {
description: 'Beschreibung für Screenreader und SEO (Pflichtfeld)',
},
},
{
name: 'caption',
type: 'text',
label: 'Bildunterschrift',
admin: {
description: 'Optionale Bildunterschrift für Darstellung unter dem Bild',
},
},
{
name: 'credit',
type: 'text',
label: 'Bildnachweis/Copyright',
admin: {
description: 'Fotograf, Agentur oder Quelle',
},
},
{
name: 'tags',
type: 'text',
hasMany: true,
label: 'Tags',
admin: {
description: 'Schlagwörter für die Suche und Filterung',
},
},
],
upload: true,
}

View file

@ -0,0 +1,158 @@
// src/collections/NewsletterSubscribers.ts
import type { CollectionConfig } from 'payload'
import { authenticatedOnly } from '../lib/tenantAccess'
/**
* Newsletter Subscribers Collection
*
* DSGVO-konforme Speicherung von Newsletter-Anmeldungen.
* Öffentlich schreibbar (Anmeldung), nur für Admins lesbar.
*/
export const NewsletterSubscribers: CollectionConfig = {
slug: 'newsletter-subscribers',
admin: {
useAsTitle: 'email',
group: 'Marketing',
defaultColumns: ['email', 'status', 'source', 'subscribedAt'],
description: 'Newsletter-Abonnenten (DSGVO-konform)',
},
access: {
// Nur Admins können Subscribers lesen (Datenschutz)
read: authenticatedOnly,
// Öffentlich subscriben möglich
create: () => true,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'email',
type: 'email',
required: true,
label: 'E-Mail-Adresse',
},
{
name: 'firstName',
type: 'text',
label: 'Vorname',
},
{
name: 'lastName',
type: 'text',
label: 'Nachname',
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'pending',
options: [
{ label: 'Ausstehend (Double Opt-In)', value: 'pending' },
{ label: 'Bestätigt', value: 'confirmed' },
{ label: 'Abgemeldet', value: 'unsubscribed' },
{ label: 'Bounced', value: 'bounced' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'interests',
type: 'select',
hasMany: true,
label: 'Interessen',
options: [
{ label: 'Allgemeine Updates', value: 'general' },
{ label: 'Blog-Artikel', value: 'blog' },
{ label: 'Produkt-News', value: 'products' },
{ label: 'Angebote & Aktionen', value: 'offers' },
{ label: 'Events', value: 'events' },
],
},
{
name: 'source',
type: 'text',
label: 'Anmeldequelle',
admin: {
description: 'z.B. "Footer", "Popup", "Blog-Artikel", "Kontakt-Seite"',
},
},
{
name: 'subscribedAt',
type: 'date',
label: 'Anmeldedatum',
admin: {
readOnly: true,
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'confirmedAt',
type: 'date',
label: 'Bestätigungsdatum',
admin: {
readOnly: true,
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'unsubscribedAt',
type: 'date',
label: 'Abmeldedatum',
admin: {
readOnly: true,
date: { pickerAppearance: 'dayAndTime' },
},
},
{
name: 'confirmationToken',
type: 'text',
label: 'Bestätigungs-Token',
admin: {
readOnly: true,
hidden: true,
},
},
{
name: 'ipAddress',
type: 'text',
label: 'IP-Adresse',
admin: {
readOnly: true,
description: 'DSGVO-Nachweis der Anmeldung',
},
},
{
name: 'userAgent',
type: 'text',
label: 'User Agent',
admin: {
readOnly: true,
hidden: true,
},
},
],
hooks: {
beforeChange: [
({ data, operation }) => {
// Automatisch Timestamps setzen
if (operation === 'create') {
data.subscribedAt = new Date().toISOString()
// Zufälliges Token für Double Opt-In
data.confirmationToken = crypto.randomUUID()
}
// Status-Änderungen tracken
if (data.status === 'confirmed' && !data.confirmedAt) {
data.confirmedAt = new Date().toISOString()
}
if (data.status === 'unsubscribed' && !data.unsubscribedAt) {
data.unsubscribedAt = new Date().toISOString()
}
return data
},
],
},
}

141
src/collections/Pages.ts Normal file
View file

@ -0,0 +1,141 @@
import type { CollectionConfig } from 'payload'
import {
HeroBlock,
TextBlock,
ImageTextBlock,
CardGridBlock,
QuoteBlock,
CTABlock,
ContactFormBlock,
TimelineBlock,
DividerBlock,
VideoBlock,
// Neue Blocks
PostsListBlock,
TestimonialsBlock,
NewsletterBlock,
ProcessStepsBlock,
} from '../blocks'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'status', 'updatedAt'],
},
access: {
read: ({ req }) => {
// Eingeloggte User sehen alles
if (req.user) return true
// Öffentlich: nur veröffentlichte Seiten
return {
status: {
equals: 'published',
},
}
},
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
localized: true,
unique: false, // Uniqueness per locale handled by index
admin: {
description: 'URL-Pfad (z.B. "ueber-uns" / "about-us")',
},
},
{
name: 'hero',
type: 'group',
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
{
name: 'headline',
type: 'text',
localized: true,
},
{
name: 'subline',
type: 'textarea',
localized: true,
},
],
},
{
name: 'layout',
type: 'blocks',
label: 'Seiteninhalt',
blocks: [
// Bestehende Blocks
HeroBlock,
TextBlock,
ImageTextBlock,
CardGridBlock,
QuoteBlock,
CTABlock,
ContactFormBlock,
TimelineBlock,
DividerBlock,
VideoBlock,
// Neue Blocks
PostsListBlock,
TestimonialsBlock,
NewsletterBlock,
ProcessStepsBlock,
],
},
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{
name: 'metaTitle',
type: 'text',
label: 'Meta-Titel',
localized: true,
},
{
name: 'metaDescription',
type: 'textarea',
label: 'Meta-Beschreibung',
localized: true,
},
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: 'Social Media Bild',
},
],
},
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: [
{ label: 'Entwurf', value: 'draft' },
{ label: 'Veröffentlicht', value: 'published' },
],
},
{
name: 'publishedAt',
type: 'date',
},
],
}

146
src/collections/Posts.ts Normal file
View file

@ -0,0 +1,146 @@
import type { CollectionConfig } from 'payload'
import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
group: 'Content',
defaultColumns: ['title', 'type', 'isFeatured', 'status', 'publishedAt'],
},
access: {
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
localized: true,
unique: false, // Uniqueness per locale handled by index
admin: {
description: 'URL-Pfad (z.B. "mein-beitrag" / "my-post")',
},
},
// === NEUE FELDER ===
{
name: 'type',
type: 'select',
required: true,
defaultValue: 'blog',
options: [
{ label: 'Blog-Artikel', value: 'blog' },
{ label: 'News/Aktuelles', value: 'news' },
{ label: 'Pressemitteilung', value: 'press' },
{ label: 'Ankündigung', value: 'announcement' },
],
admin: {
position: 'sidebar',
description: 'Art des Beitrags',
},
},
{
name: 'isFeatured',
type: 'checkbox',
defaultValue: false,
label: 'Hervorgehoben',
admin: {
position: 'sidebar',
description: 'Auf Startseite/oben anzeigen',
},
},
{
name: 'excerpt',
type: 'textarea',
label: 'Kurzfassung',
maxLength: 300,
localized: true,
admin: {
description: 'Für Übersichten und SEO (max. 300 Zeichen). Wird automatisch aus Content generiert, falls leer.',
},
},
// === ENDE NEUE FELDER ===
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
label: 'Beitragsbild',
},
{
name: 'content',
type: 'richText',
required: true,
localized: true,
},
{
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
},
{
name: 'author',
type: 'text',
label: 'Autor',
},
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: [
{ label: 'Entwurf', value: 'draft' },
{ label: 'Veröffentlicht', value: 'published' },
{ label: 'Archiviert', value: 'archived' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'publishedAt',
type: 'date',
label: 'Veröffentlichungsdatum',
admin: {
position: 'sidebar',
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{
name: 'metaTitle',
type: 'text',
label: 'Meta-Titel',
localized: true,
},
{
name: 'metaDescription',
type: 'textarea',
label: 'Meta-Beschreibung',
maxLength: 160,
localized: true,
},
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: 'Social Media Bild',
},
],
},
],
}

View file

@ -0,0 +1,263 @@
// src/collections/PrivacyPolicySettings.ts
import type { CollectionConfig } from 'payload'
import { tenantScopedPublicRead, authenticatedOnly } from '../lib/tenantAccess'
/**
* PrivacyPolicySettings Collection
*
* Konfiguration für externe Datenschutzerklärung (Alfright) pro Tenant.
* Öffentlich lesbar (für Frontend), aber tenant-isoliert.
*/
export const PrivacyPolicySettings: CollectionConfig = {
slug: 'privacy-policy-settings',
admin: {
useAsTitle: 'title',
group: 'Consent Management',
description: 'Externe Datenschutzerklärung Konfiguration (Alfright)',
},
access: {
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
unique: true,
admin: {
description: 'Jeder Tenant kann nur eine Konfiguration haben',
},
},
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Datenschutzerklärung',
admin: {
description: 'Interner Titel zur Identifikation',
},
},
{
name: 'provider',
type: 'select',
required: true,
defaultValue: 'alfright',
options: [
{ label: 'Alfright (extern via iframe)', value: 'alfright' },
{ label: 'Eigener Text (nicht implementiert)', value: 'internal' },
],
admin: {
description: 'Quelle der Datenschutzerklärung',
},
},
// Alfright Konfiguration
{
name: 'alfright',
type: 'group',
label: 'Alfright Konfiguration',
admin: {
condition: (data) => data?.provider === 'alfright',
description: 'Einstellungen für die Alfright Integration',
},
fields: [
{
name: 'tenantId',
type: 'text',
required: true,
defaultValue: 'alfright_schutzteam',
admin: {
description: 'Alfright Tenant-ID (aus dem iframe-Code)',
},
},
{
name: 'apiKey',
type: 'text',
required: true,
admin: {
description: 'Alfright API-Key / Dokument-ID (aus dem iframe-Code, z.B. "9f315103c43245bcb0806dd56c2be757")',
},
},
{
name: 'language',
type: 'select',
required: true,
defaultValue: 'de-de',
options: [
{ label: 'Deutsch (Deutschland)', value: 'de-de' },
{ label: 'Deutsch (Österreich)', value: 'de-at' },
{ label: 'Deutsch (Schweiz)', value: 'de-ch' },
{ label: 'Englisch (UK)', value: 'en-gb' },
{ label: 'Englisch (US)', value: 'en-us' },
],
admin: {
description: 'Sprache der Datenschutzerklärung',
},
},
{
name: 'iframeHeight',
type: 'number',
required: true,
defaultValue: 4000,
min: 500,
max: 10000,
admin: {
description: 'Höhe des iframes in Pixeln (empfohlen: 3000-5000)',
},
},
],
},
// Styling (passend zum Website-Theme)
{
name: 'styling',
type: 'group',
label: 'Styling',
admin: {
condition: (data) => data?.provider === 'alfright',
description: 'Farben und Schriften an das Website-Design anpassen',
},
fields: [
{
name: 'headerColor',
type: 'text',
required: true,
defaultValue: '#ca8a04',
admin: {
description: 'Farbe der Überschriften (Hex-Code, z.B. #ca8a04 für Gold)',
},
},
{
name: 'headerFont',
type: 'text',
required: true,
defaultValue: 'Inter, sans-serif',
admin: {
description: 'Schriftart der Überschriften',
},
},
{
name: 'headerSize',
type: 'text',
required: true,
defaultValue: '24px',
admin: {
description: 'Schriftgröße der Hauptüberschriften',
},
},
{
name: 'subheaderSize',
type: 'text',
required: true,
defaultValue: '18px',
admin: {
description: 'Schriftgröße der Unterüberschriften',
},
},
{
name: 'fontColor',
type: 'text',
required: true,
defaultValue: '#f3f4f6',
admin: {
description: 'Textfarbe (Hex-Code, z.B. #f3f4f6 für hellen Text)',
},
},
{
name: 'textFont',
type: 'text',
required: true,
defaultValue: 'Inter, sans-serif',
admin: {
description: 'Schriftart für Fließtext',
},
},
{
name: 'textSize',
type: 'text',
required: true,
defaultValue: '16px',
admin: {
description: 'Schriftgröße für Fließtext',
},
},
{
name: 'linkColor',
type: 'text',
required: true,
defaultValue: '#ca8a04',
admin: {
description: 'Linkfarbe (Hex-Code)',
},
},
{
name: 'backgroundColor',
type: 'text',
required: true,
defaultValue: '#111827',
admin: {
description: 'Hintergrundfarbe (Hex-Code, z.B. #111827 für Dark Theme)',
},
},
],
},
// Cookie-Tabelle Option
{
name: 'showCookieTable',
type: 'checkbox',
defaultValue: true,
admin: {
description: 'Cookie-Tabelle aus CookieInventory unterhalb der Datenschutzerklärung anzeigen',
},
},
{
name: 'cookieTableTitle',
type: 'text',
defaultValue: 'Übersicht der verwendeten Cookies',
admin: {
condition: (data) => data?.showCookieTable,
description: 'Überschrift für die Cookie-Tabelle',
},
},
{
name: 'cookieTableDescription',
type: 'textarea',
defaultValue: 'Ergänzend zur Datenschutzerklärung finden Sie hier eine detaillierte Übersicht aller auf dieser Website eingesetzten Cookies. Sie können Ihre Cookie-Einstellungen jederzeit über den Link "Cookie-Einstellungen" im Footer anpassen.',
admin: {
condition: (data) => data?.showCookieTable,
description: 'Einleitungstext für die Cookie-Tabelle',
},
},
// SEO
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{
name: 'metaTitle',
type: 'text',
defaultValue: 'Datenschutzerklärung',
admin: {
description: 'Meta-Titel für die Seite',
},
},
{
name: 'metaDescription',
type: 'textarea',
defaultValue: 'Informationen zum Datenschutz und zur Verarbeitung Ihrer personenbezogenen Daten.',
admin: {
description: 'Meta-Beschreibung für Suchmaschinen',
},
},
],
},
],
}

View file

@ -0,0 +1,39 @@
import type { CollectionConfig } from 'payload'
export const SocialLinks: CollectionConfig = {
slug: 'social-links',
admin: {
useAsTitle: 'platform',
},
access: {
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{
name: 'platform',
type: 'select',
required: true,
options: [
{ label: 'Facebook', value: 'facebook' },
{ label: 'X (Twitter)', value: 'x' },
{ label: 'Instagram', value: 'instagram' },
{ label: 'YouTube', value: 'youtube' },
{ label: 'LinkedIn', value: 'linkedin' },
{ label: 'Xing', value: 'xing' },
],
},
{
name: 'url',
type: 'text',
required: true,
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
},
],
}

View file

@ -0,0 +1,122 @@
// src/collections/Testimonials.ts
import type { CollectionConfig } from 'payload'
import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess'
/**
* Testimonials Collection
*
* Kundenbewertungen und Referenzen, wiederverwendbar auf allen Seiten.
* Tenant-scoped für Multi-Tenant-Betrieb.
*/
export const Testimonials: CollectionConfig = {
slug: 'testimonials',
admin: {
useAsTitle: 'author',
group: 'Content',
defaultColumns: ['author', 'company', 'rating', 'isActive'],
description: 'Kundenstimmen und Bewertungen',
},
access: {
read: tenantScopedPublicRead,
create: authenticatedOnly,
update: authenticatedOnly,
delete: authenticatedOnly,
},
fields: [
{
name: 'quote',
type: 'textarea',
required: true,
label: 'Zitat/Bewertung',
localized: true,
admin: {
description: 'Die Aussage des Kunden',
},
},
{
name: 'author',
type: 'text',
required: true,
label: 'Name',
// author bleibt nicht lokalisiert - Name ist sprachunabhängig
},
{
name: 'role',
type: 'text',
label: 'Position/Rolle',
localized: true,
admin: {
description: 'z.B. "Patient", "Geschäftsführer", "Marketing Manager"',
},
},
{
name: 'company',
type: 'text',
label: 'Unternehmen/Organisation',
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
label: 'Foto',
admin: {
description: 'Portrait-Foto (empfohlen: quadratisch, min. 200x200px)',
},
},
{
name: 'rating',
type: 'number',
min: 1,
max: 5,
label: 'Bewertung (1-5 Sterne)',
admin: {
description: 'Optional: Sterne-Bewertung',
},
},
{
name: 'source',
type: 'text',
label: 'Quelle',
admin: {
description: 'z.B. "Google Reviews", "Trustpilot", "Persönlich"',
},
},
{
name: 'sourceUrl',
type: 'text',
label: 'Link zur Quelle',
admin: {
description: 'URL zur Original-Bewertung (falls öffentlich)',
},
},
{
name: 'date',
type: 'date',
label: 'Datum der Bewertung',
admin: {
position: 'sidebar',
},
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
label: 'Aktiv/Sichtbar',
admin: {
position: 'sidebar',
description: 'Inaktive Testimonials werden nicht angezeigt',
},
},
{
name: 'order',
type: 'number',
defaultValue: 0,
label: 'Sortierung',
admin: {
position: 'sidebar',
description: 'Niedrigere Zahlen werden zuerst angezeigt',
},
},
],
}