# BlogWoman.de - Payload CMS Erweiterungen **Projekt:** cms.c2sgmbh (Payload CMS Multi-Tenant) **Ziel:** Neue Collections und Blocks für blogwoman.de **Server:** sv-payload (10.10.181.100) / Production: Hetzner 3 (162.55.85.18) **Repository:** github.com/complexcaresolutions/cms.c2sgmbh --- ## Kontext Das Payload CMS Multi-Tenant-System wird um Features für blogwoman.de erweitert. BlogWoman ist ein Lifestyle-Blog für berufstätige Frauen mit Fokus auf: - Affiliate-Produkte (Favoriten) - YouTube-Serien mit eigenem Branding - Newsletter-Conversions - SEO-optimierter Blog-Content **Tenant:** blogwoman (muss in Tenants Collection angelegt werden, ID wird dynamisch) --- ## Bestehende Infrastruktur ### Vorhandene Collections (NICHT ändern) - Users, Tenants, Media, Pages, Posts, Categories - Testimonials, FAQs, Team, Services - NewsletterSubscribers, Videos, VideoCategories - CookieConfigurations, CookieInventory, ConsentLogs - AuditLogs, FormSubmissions ### Vorhandene Blocks (NICHT ändern) - HeroBlock, HeroSliderBlock, TextBlock, ImageTextBlock - CardGridBlock, QuoteBlock, CTABlock, VideoBlock - PostsListBlock, TestimonialsBlock, NewsletterBlock - FAQBlock, TeamBlock, ServicesBlock, TimelineBlock - ProcessStepsBlock, DividerBlock, ContactFormBlock ### Tech Stack - Payload CMS 3.69.0 - Next.js 15.5.9 - React 19.2.3 - PostgreSQL 17 - TypeScript - pnpm --- ## Phase 1: Favorites Collection & Block ### 1.1 Collection: `favorites` **Datei:** `src/collections/Favorites.ts` ```typescript // Struktur: import type { CollectionConfig } from 'payload' export const Favorites: CollectionConfig = { slug: 'favorites', admin: { group: 'Content', useAsTitle: 'title', defaultColumns: ['title', 'category', 'featured', 'isActive'], }, access: { read: () => true, // Public, aber Tenant-gefiltert create: ({ req: { user } }) => !!user, update: ({ req: { user } }) => !!user, delete: ({ req: { user } }) => !!user, }, fields: [ // ... siehe Feld-Definition unten ], } ``` **Felder:** | Feld | Typ | Config | Pflicht | |------|-----|--------|---------| | `title` | text | maxLength: 200 | required | | `slug` | text | unique, admin.position: 'sidebar' | required | | `description` | textarea | maxLength: 300 | - | | `category` | select | options: siehe unten | required | | `subcategory` | text | maxLength: 100 | - | | `price` | number | min: 0 | - | | `priceRange` | select | budget, mid, premium, luxury | - | | `affiliateUrl` | text | URL-Validierung | required | | `affiliateNetwork` | select | amazon, awin, ltk, direct, other | - | | `image` | upload | relationTo: 'media' | required | | `badge` | select | options: siehe unten | - | | `featured` | checkbox | defaultValue: false | - | | `isActive` | checkbox | defaultValue: true | required | | `order` | number | defaultValue: 0 | - | | `tenant` | relationship | relationTo: 'tenants' | required | **Category Options:** ```typescript const categoryOptions = [ { label: 'Fashion', value: 'fashion' }, { label: 'Beauty', value: 'beauty' }, { label: 'Travel', value: 'travel' }, { label: 'Tech', value: 'tech' }, { label: 'Home', value: 'home' }, ] ``` **Badge Options:** ```typescript const badgeOptions = [ { label: 'Investment Piece', value: 'investment-piece' }, { label: 'Daily Driver', value: 'daily-driver' }, { label: 'GRFI Approved', value: 'grfi-approved' }, { label: 'Neu', value: 'new' }, { label: 'Bestseller', value: 'bestseller' }, ] ``` **Price Range Options:** ```typescript const priceRangeOptions = [ { label: 'Budget (< €50)', value: 'budget' }, { label: 'Mid (€50-150)', value: 'mid' }, { label: 'Premium (€150-500)', value: 'premium' }, { label: 'Luxury (> €500)', value: 'luxury' }, ] ``` ### 1.2 Block: `favorites-block` **Datei:** `src/blocks/FavoritesBlock.ts` ```typescript import type { Block } from 'payload' export const FavoritesBlock: Block = { slug: 'favorites-block', labels: { singular: 'Favoriten', plural: 'Favoriten', }, fields: [ // ... siehe unten ], } ``` **Block-Felder:** | Feld | Typ | Default | |------|-----|---------| | `title` | text | - | | `subtitle` | text | - | | `category` | select | 'all' (+ alle category options) | | `showFeaturedOnly` | checkbox | false | | `limit` | number | 8 | | `layout` | select | 'grid' (grid, list, carousel) | | `columns` | select | '4' (2, 3, 4) | | `showPrice` | checkbox | true | | `showBadge` | checkbox | true | | `backgroundColor` | select | 'white' | --- ## Phase 2: Series Collection & Blocks ### 2.1 Collection: `series` **Datei:** `src/collections/Series.ts` **Felder:** | Feld | Typ | Config | Pflicht | |------|-----|--------|---------| | `title` | text | localized: true | required | | `slug` | text | unique | required | | `tagline` | text | localized: true, maxLength: 150 | - | | `description` | richText | localized: true | - | | `logo` | upload | relationTo: 'media' | - | | `coverImage` | upload | relationTo: 'media' | - | | `brandColor` | text | placeholder: '#B08D57' | - | | `accentColor` | text | placeholder: '#FFFFFF' | - | | `youtubePlaylistId` | text | - | - | | `youtubePlaylistUrl` | text | URL-Validierung | - | | `order` | number | defaultValue: 0 | - | | `isActive` | checkbox | defaultValue: true | required | | `tenant` | relationship | relationTo: 'tenants' | required | **Admin Config:** ```typescript admin: { group: 'Content', useAsTitle: 'title', defaultColumns: ['title', 'slug', 'brandColor', 'isActive'], } ``` ### 2.2 Block: `series-block` **Datei:** `src/blocks/SeriesBlock.ts` **Felder:** | Feld | Typ | Default | |------|-----|---------| | `title` | text | - | | `subtitle` | text | - | | `layout` | select | 'grid' (grid, list, featured) | | `showDescription` | checkbox | true | | `showLogo` | checkbox | true | | `limit` | number | 6 | | `columns` | select | '3' (2, 3, 4) | | `backgroundColor` | select | 'white' | ### 2.3 Block: `series-detail-block` **Datei:** `src/blocks/SeriesDetailBlock.ts` Für Serien-Einzelseiten mit Hero und Content. **Felder:** | Feld | Typ | Default | |------|-----|---------| | `series` | relationship | relationTo: 'series' | | `showHero` | checkbox | true | | `showDescription` | checkbox | true | | `showRelatedPosts` | checkbox | true | | `relatedPostsLimit` | number | 6 | | `layout` | select | 'full' (full, compact) | --- ## Phase 3: Video Embed Block ### 3.1 Block: `video-embed-block` **Datei:** `src/blocks/VideoEmbedBlock.ts` **Felder:** | Feld | Typ | Config | |------|-----|--------| | `title` | text | optional | | `videoSource` | select | youtube, vimeo, custom | | `youtubeUrl` | text | condition: videoSource === 'youtube' | | `vimeoUrl` | text | condition: videoSource === 'vimeo' | | `customUrl` | text | condition: videoSource === 'custom' | | `thumbnail` | upload | relationTo: 'media', optional | | `caption` | text | localized: true | | `privacyMode` | checkbox | defaultValue: true | | `aspectRatio` | select | '16:9' (16:9, 4:3, 1:1, 9:16) | | `maxWidth` | select | 'large' (full, large, medium, small) | | `lazyLoad` | checkbox | defaultValue: true | **YouTube URL Parser (Utility):** ```typescript // src/lib/utils/youtube.ts export function extractYouTubeId(url: string): string | null { if (!url) return null const patterns = [ /youtube\.com\/watch\?v=([^&]+)/, /youtu\.be\/([^?]+)/, /youtube\.com\/embed\/([^?]+)/, /youtube\.com\/shorts\/([^?]+)/, ] for (const pattern of patterns) { const match = url.match(pattern) if (match) return match[1] } return null } export function getYouTubeEmbedUrl(videoId: string, privacyMode = true): string { const domain = privacyMode ? 'www.youtube-nocookie.com' : 'www.youtube.com' return `https://${domain}/embed/${videoId}` } export function getYouTubeThumbnail(videoId: string, quality: 'default' | 'hq' | 'maxres' = 'hq'): string { const qualityMap = { default: 'default', hq: 'hqdefault', maxres: 'maxresdefault', } return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}.jpg` } ``` --- ## Phase 4: Stats Block ### 4.1 Block: `stats-block` **Datei:** `src/blocks/StatsBlock.ts` **Felder:** | Feld | Typ | Config | |------|-----|--------| | `title` | text | optional | | `subtitle` | text | optional | | `stats` | array | siehe Stats Item | | `layout` | select | 'row' (row, grid, cards) | | `columns` | select | '4' (2, 3, 4, 5) | | `animated` | checkbox | defaultValue: false | | `backgroundColor` | select | 'white' (white, ivory, sand, dark) | **Stats Array Item:** | Feld | Typ | Config | |------|-----|--------| | `value` | text | required, z.B. "42.000" | | `prefix` | text | optional, z.B. "€", ">" | | `suffix` | text | optional, z.B. "+", "%", "K" | | `label` | text | required, localized | | `description` | text | optional, localized | | `icon` | text | optional, Lucide icon name | --- ## Phase 5: Featured Content Block ### 5.1 Block: `featured-content-block` **Datei:** `src/blocks/FeaturedContentBlock.ts` **Felder:** | Feld | Typ | Config | |------|-----|--------| | `title` | text | localized | | `subtitle` | text | localized | | `items` | array | siehe Items Array | | `layout` | select | 'grid' (grid, carousel, list, featured-grid) | | `columns` | select | '3' (2, 3, 4) | | `showDates` | checkbox | defaultValue: true | | `backgroundColor` | select | 'white' | **Items Array:** | Feld | Typ | Config | |------|-----|--------| | `itemType` | select | required: post, video, series, external | | `post` | relationship | relationTo: 'posts', condition: itemType === 'post' | | `video` | relationship | relationTo: 'videos', condition: itemType === 'video' | | `series` | relationship | relationTo: 'series', condition: itemType === 'series' | | `externalTitle` | text | condition: itemType === 'external' | | `externalUrl` | text | condition: itemType === 'external' | | `externalImage` | upload | relationTo: 'media', condition: itemType === 'external' | | `externalDescription` | textarea | condition: itemType === 'external' | | `customLabel` | text | optional, z.B. "NEU", "TRENDING" | --- ## Implementierungs-Reihenfolge ### Schritt 1: Collections erstellen 1. `src/collections/Favorites.ts` 2. `src/collections/Series.ts` ### Schritt 2: Collections in payload.config.ts registrieren ```typescript // In payload.config.ts import { Favorites } from './collections/Favorites' import { Series } from './collections/Series' // In collections Array hinzufügen: collections: [ // ... bestehende Favorites, Series, ], ``` ### Schritt 3: Blocks erstellen 1. `src/blocks/FavoritesBlock.ts` 2. `src/blocks/SeriesBlock.ts` 3. `src/blocks/SeriesDetailBlock.ts` 4. `src/blocks/VideoEmbedBlock.ts` 5. `src/blocks/StatsBlock.ts` 6. `src/blocks/FeaturedContentBlock.ts` ### Schritt 4: Blocks in Pages registrieren ```typescript // In src/collections/Pages.ts -> layout field -> blocks array import { FavoritesBlock } from '../blocks/FavoritesBlock' import { SeriesBlock } from '../blocks/SeriesBlock' import { SeriesDetailBlock } from '../blocks/SeriesDetailBlock' import { VideoEmbedBlock } from '../blocks/VideoEmbedBlock' import { StatsBlock } from '../blocks/StatsBlock' import { FeaturedContentBlock } from '../blocks/FeaturedContentBlock' // blocks: [ // ... bestehende, // FavoritesBlock, // SeriesBlock, // SeriesDetailBlock, // VideoEmbedBlock, // StatsBlock, // FeaturedContentBlock, // ] ``` ### Schritt 5: Utility-Funktionen erstellen 1. `src/lib/utils/youtube.ts` ### Schritt 6: Migration erstellen ```bash pnpm payload migrate:create blogwoman_collections ``` ### Schritt 7: Build & Test ```bash pnpm lint --fix pnpm build ``` --- ## Erfolgskriterien ### Funktional - [ ] Favorites Collection: CRUD über Admin Panel funktioniert - [ ] Favorites: Filterung nach Kategorie in API (`/api/favorites?where[category][equals]=fashion`) - [ ] Favorites: Tenant-Isolation funktioniert - [ ] Favorites Block: In Pages verwendbar - [ ] Series Collection: CRUD funktioniert - [ ] Series: Localization (de/en) funktioniert - [ ] Series Block: In Pages verwendbar - [ ] Series Detail Block: Zeigt Serie mit related Posts - [ ] Video Embed Block: YouTube-URLs werden korrekt geparst - [ ] Video Embed Block: Privacy Mode (youtube-nocookie) funktioniert - [ ] Stats Block: Zeigt Statistiken in verschiedenen Layouts - [ ] Featured Content Block: Verschiedene Content-Typen mischbar ### Technisch - [ ] `pnpm lint` ohne Errors - [ ] `pnpm build` erfolgreich - [ ] Keine TypeScript-Fehler - [ ] Migration erstellt und ausführbar - [ ] API-Endpoints erreichbar: - GET /api/favorites - GET /api/series ### Admin UX - [ ] Collections erscheinen unter "Content" Gruppe - [ ] Blocks haben deutsche Labels - [ ] Conditional Fields funktionieren (z.B. bei itemType) --- ## Escape Hatch Falls nach 20+ Iterationen blockiert: 1. **Dokumentiere in BLOCKERS.md:** - Was genau nicht funktioniert - Fehlermeldungen (vollständig) - Versuchte Lösungsansätze 2. **Teile das Problem auf:** - Nur Collections ohne Blocks - Nur einen Block isoliert 3. **Prüfe Abhängigkeiten:** - Ist Payload-Version kompatibel? - Fehlen Import-Statements? - Gibt es Naming-Konflikte? --- ## Referenzen ### Bestehende Collection als Vorlage - `src/collections/Posts.ts` (für Struktur) - `src/collections/Testimonials.ts` (für einfache Collection) - `src/collections/Videos.ts` (falls vorhanden) ### Bestehende Blocks als Vorlage - `src/blocks/PostsListBlock.ts` - `src/blocks/TestimonialsBlock.ts` - `src/blocks/CardGridBlock.ts` ### Payload Docs - Collections: https://payloadcms.com/docs/configuration/collections - Blocks: https://payloadcms.com/docs/fields/blocks - Conditional Logic: https://payloadcms.com/docs/fields/overview#conditional-logic --- ## Verzeichnis ``` src/ ├── collections/ │ ├── Favorites.ts # NEU │ └── Series.ts # NEU ├── blocks/ │ ├── FavoritesBlock.ts # NEU │ ├── SeriesBlock.ts # NEU │ ├── SeriesDetailBlock.ts # NEU │ ├── VideoEmbedBlock.ts # NEU │ ├── StatsBlock.ts # NEU │ └── FeaturedContentBlock.ts # NEU ├── lib/ │ └── utils/ │ └── youtube.ts # NEU └── migrations/ └── YYYYMMDD_HHMMSS_blogwoman_collections.ts # NEU (auto-generiert) ``` --- ## Fertig? Wenn ALLE Erfolgskriterien erfüllt sind: - Alle Collections erstellt und registriert - Alle Blocks erstellt und in Pages registriert - `pnpm lint` ohne Errors - `pnpm build` erfolgreich - Migration erstellt Dann schreibe als letzte Zeile: BLOGWOMAN_PAYLOAD_COMPLETE