diff --git a/src/collections/Categories.ts b/src/collections/Categories.ts new file mode 100644 index 0000000..070ddab --- /dev/null +++ b/src/collections/Categories.ts @@ -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, + }, + ], +} diff --git a/src/collections/ConsentLogs.ts b/src/collections/ConsentLogs.ts new file mode 100644 index 0000000..709e8c6 --- /dev/null +++ b/src/collections/ConsentLogs.ts @@ -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 + const apiKey = + typeof headers.get === 'function' + ? headers.get('x-api-key') + : (headers as Record)['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', + }, + }, + }, + ], +} diff --git a/src/collections/CookieConfigurations.ts b/src/collections/CookieConfigurations.ts new file mode 100644 index 0000000..a3a3e30 --- /dev/null +++ b/src/collections/CookieConfigurations.ts @@ -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' }, + ], + }, + ], + }, + ], +} diff --git a/src/collections/CookieInventory.ts b/src/collections/CookieInventory.ts new file mode 100644 index 0000000..ec8c379 --- /dev/null +++ b/src/collections/CookieInventory.ts @@ -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, + }, + ], +} diff --git a/src/collections/Media.ts b/src/collections/Media.ts index 568cf42..71b36b6 100644 --- a/src/collections/Media.ts +++ b/src/collections/Media.ts @@ -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, } diff --git a/src/collections/NewsletterSubscribers.ts b/src/collections/NewsletterSubscribers.ts new file mode 100644 index 0000000..77a2f59 --- /dev/null +++ b/src/collections/NewsletterSubscribers.ts @@ -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 + }, + ], + }, +} diff --git a/src/collections/Pages.ts b/src/collections/Pages.ts new file mode 100644 index 0000000..002b6d2 --- /dev/null +++ b/src/collections/Pages.ts @@ -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', + }, + ], +} diff --git a/src/collections/Posts.ts b/src/collections/Posts.ts new file mode 100644 index 0000000..52a8f3b --- /dev/null +++ b/src/collections/Posts.ts @@ -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', + }, + ], + }, + ], +} diff --git a/src/collections/PrivacyPolicySettings.ts b/src/collections/PrivacyPolicySettings.ts new file mode 100644 index 0000000..b3285e2 --- /dev/null +++ b/src/collections/PrivacyPolicySettings.ts @@ -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', + }, + }, + ], + }, + ], +} diff --git a/src/collections/SocialLinks.ts b/src/collections/SocialLinks.ts new file mode 100644 index 0000000..87f33f1 --- /dev/null +++ b/src/collections/SocialLinks.ts @@ -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, + }, + ], +} diff --git a/src/collections/Testimonials.ts b/src/collections/Testimonials.ts new file mode 100644 index 0000000..e59c5c3 --- /dev/null +++ b/src/collections/Testimonials.ts @@ -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', + }, + }, + ], +}