mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
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:
parent
82c89f1494
commit
885ec93748
11 changed files with 1601 additions and 1 deletions
34
src/collections/Categories.ts
Normal file
34
src/collections/Categories.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
225
src/collections/ConsentLogs.ts
Normal file
225
src/collections/ConsentLogs.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
204
src/collections/CookieConfigurations.ts
Normal file
204
src/collections/CookieConfigurations.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
83
src/collections/CookieInventory.ts
Normal file
83
src/collections/CookieInventory.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -2,15 +2,200 @@ import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
export const Media: CollectionConfig = {
|
export const Media: CollectionConfig = {
|
||||||
slug: 'media',
|
slug: 'media',
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'alt',
|
||||||
|
group: 'Medien',
|
||||||
|
description: 'Bilder und Dokumente mit automatischer Optimierung',
|
||||||
|
},
|
||||||
access: {
|
access: {
|
||||||
read: () => true,
|
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: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'alt',
|
name: 'alt',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: true,
|
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,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
158
src/collections/NewsletterSubscribers.ts
Normal file
158
src/collections/NewsletterSubscribers.ts
Normal 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
141
src/collections/Pages.ts
Normal 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
146
src/collections/Posts.ts
Normal 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
263
src/collections/PrivacyPolicySettings.ts
Normal file
263
src/collections/PrivacyPolicySettings.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
39
src/collections/SocialLinks.ts
Normal file
39
src/collections/SocialLinks.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
122
src/collections/Testimonials.ts
Normal file
122
src/collections/Testimonials.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue