feat: initial payload-contracts package

Shared TypeScript types, API client, and block registry for
coordinated CMS-to-frontend development across all tenants.

- Type extraction script from payload-types.ts (12,782 lines)
- 39 frontend collection types, 42 block types
- createPayloadClient() with tenant isolation
- createBlockRenderer() for type-safe block mapping
- Media helpers (getImageUrl, getSrcSet)
- Work order system for cross-server coordination
- Block catalog documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-14 17:51:40 +00:00
commit 774d7bc402
26 changed files with 14762 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local

71
CLAUDE.md Normal file
View file

@ -0,0 +1,71 @@
# payload-contracts
Shared TypeScript types, API client, and block registry for Payload CMS multi-tenant frontends.
## Purpose
This package is the single source of truth for TypeScript types between the CMS (sv-payload) and all frontend sites (sv-frontend / Plesk production servers). It prevents type drift and provides a consistent API client across all frontends.
## Architecture
```
CMS (payload-cms) Contracts (this repo) Frontends
━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━
payload-types.ts ──extract-types──→ src/types/payload-types.ts
src/types/collections.ts ←──── import { Page, Post }
src/types/blocks.ts ←──── import { Block, BlockByType }
src/types/media.ts ←──── import { getImageUrl }
src/types/api.ts ←──── import { PaginatedResponse }
src/api-client/ ←──── import { createPayloadClient }
src/blocks/registry.ts ←──── import { createBlockRenderer }
```
## Commands
```bash
pnpm install # Install dependencies
pnpm build # Compile TypeScript
pnpm typecheck # Type-check without emitting
pnpm extract # Re-extract types from CMS (run on sv-payload)
```
## Updating Types
When CMS collections or blocks change:
1. On sv-payload: `cd ~/payload-cms && pnpm payload generate:types`
2. On sv-payload: `cd ~/payload-contracts && pnpm extract`
3. Review changes, commit, push
4. Write a work order in `work-orders/` if frontends need updating
## Key Files
| File | Purpose |
|------|---------|
| `src/types/payload-types.ts` | Full auto-generated types from CMS |
| `src/types/collections.ts` | Curated frontend collection re-exports |
| `src/types/blocks.ts` | Block union type + BlockByType helper |
| `src/types/media.ts` | Media type + image URL helpers |
| `src/types/api.ts` | PaginatedResponse, query params, error types |
| `src/api-client/index.ts` | `createPayloadClient()` factory |
| `src/blocks/registry.ts` | `createBlockRenderer()` factory |
| `src/constants/tenants.ts` | Tenant IDs and slugs |
| `scripts/extract-types.ts` | Type extraction from payload-types.ts |
## Tenants
| ID | Slug | Site |
|----|------|------|
| 1 | porwoll | porwoll.de |
| 4 | c2s | complexcaresolutions.de |
| 5 | gunshin | gunshin.de |
| 9 | blogwoman | blogwoman.de |
## Work Orders
The `work-orders/` directory contains coordination files for propagating CMS changes to frontends. See `work-orders/_template.md` for the format.
## Git Workflow
- `main` branch only — tag releases as `v1.x.x`
- Frontends consume via Git dependency in package.json

131
docs/BLOCK_CATALOG.md Normal file
View file

@ -0,0 +1,131 @@
# Block Catalog
All 43 CMS blocks available for frontend implementation.
Auto-generated reference — see `src/types/blocks.ts` for exact TypeScript types.
## How to Use
```typescript
import type { Block, BlockByType } from '@c2s/payload-contracts/types'
// Get a specific block type
type HeroBlock = BlockByType<'hero-block'>
// Switch on block type (discriminated union)
function renderBlock(block: Block) {
switch (block.blockType) {
case 'hero-block': return <Hero {...block} />
case 'text-block': return <Text {...block} />
// ...
}
}
```
## Core Blocks
| # | Slug | Name | Key Fields |
|---|------|------|------------|
| 1 | `hero-block` | Hero | title, subtitle, image, ctaButtons, style, overlay |
| 2 | `hero-slider-block` | Hero Slider | slides[], autoplay, animation, interval |
| 3 | `image-slider-block` | Image Slider | images[], layout, columns |
| 4 | `text-block` | Text | content (richText), alignment, maxWidth |
| 5 | `image-text-block` | Image + Text | image, content, imagePosition, ratio |
| 6 | `card-grid-block` | Card Grid | cards[], columns, layout |
| 7 | `quote-block` | Quote | quote, author, role, image, style |
| 8 | `cta-block` | Call to Action | title, description, buttons[], style |
| 9 | `contact-form-block` | Contact Form | form (relationship), layout |
| 10 | `timeline-block` | Timeline | entries (from Timeline collection) |
| 11 | `divider-block` | Divider | style, spacing, color |
| 12 | `video-block` | Video | videoUrl, poster, autoplay, aspectRatio |
## Content Blocks
| # | Slug | Name | Key Fields |
|---|------|------|------------|
| 13 | `posts-list-block` | Posts List | postType, layout, limit, showExcerpt, showDate |
| 14 | `testimonials-block` | Testimonials | displayMode, layout, autoplay |
| 15 | `newsletter-block` | Newsletter | title, description, buttonText |
| 16 | `process-steps-block` | Process Steps | steps[], layout, numbering |
| 17 | `faq-block` | FAQ | displayMode, categories, layout, schema (JSON-LD) |
| 18 | `team-block` | Team | displayMode, roles, layout, columns |
| 19 | `services-block` | Services | displayMode, categories, layout |
## Blogging Blocks
| # | Slug | Name | Key Fields |
|---|------|------|------------|
| 20 | `author-bio-block` | Author Bio | author (relationship), showSocial |
| 21 | `related-posts-block` | Related Posts | limit, layout, strategy |
| 22 | `share-buttons-block` | Share Buttons | platforms[], layout |
| 23 | `toc-block` | Table of Contents | style, maxDepth |
## Team Blocks
| # | Slug | Name | Key Fields |
|---|------|------|------------|
| 24 | `team-filter-block` | Team Filter | roles[], layout, filterLayout |
| 25 | `org-chart-block` | Org Chart | maxDepth, layout, direction, nodeStyle |
## Feature Blocks
| # | Slug | Name | Key Fields |
|---|------|------|------------|
| 26 | `locations-block` | Locations | type, selectedLocations, layout, map |
| 27 | `logo-grid-block` | Logo Grid | logos[], layout, columns, slider |
| 28 | `stats-block` | Stats | stats[], layout, columns, animation |
| 29 | `jobs-block` | Jobs | displayMode, layout, columns, filters |
| 30 | `downloads-block` | Downloads | displayMode, layout, columns, filters |
| 31 | `map-block` | Map | center, zoom, markers, mapStyle |
## Interactive Blocks
| # | Slug | Name | Key Fields |
|---|------|------|------------|
| 32 | `events` | Events | displayMode, layout, columns, showImage |
| 33 | `pricing` | Pricing | plans[], layout, highlightPopular |
| 34 | `tabs` | Tabs | tabs[], tabStyle, defaultTab |
| 35 | `accordion` | Accordion | items[], style, iconPosition, layout |
| 36 | `comparison` | Comparison | items[], features[], layout |
## Tenant-Specific Blocks
| # | Slug | Name | Tenant | Key Fields |
|---|------|------|--------|------------|
| 37 | `before-after` | Before/After | porwoll | pairs[], sliderStyle |
## BlogWoman Blocks
| # | Slug | Name | Key Fields |
|---|------|------|------------|
| 38 | `favorites-block` | Favorites | displayMode, layout, columns, filterCategory |
| 39 | `series-block` | Series | displayMode, layout, columns |
| 40 | `series-detail-block` | Series Detail | series (relationship), showHero |
| 41 | `video-embed-block` | Video Embed | videoUrl, aspectRatio, privacyMode |
| 42 | `featured-content-block` | Featured Content | items[], layout |
## YouTube Script Block (not in page layout)
| # | Slug | Name | Used In |
|---|------|------|---------|
| 43 | `script-section` | Script Section | yt-script-templates |
## Common Patterns
All blocks share these optional fields (via block config):
- `title` — Section heading
- `subtitle` — Section subheading
- `description` — Rich text description
- `backgroundColor` — Background color variant
- `spacing` — Top/bottom padding control
- `id` — Unique block ID (auto-generated)
## Block Groups by Frontend
### blogwoman.de (~17 blocks)
Core (hero, text, image-text, cta, video, divider) + Content (posts-list, testimonials, newsletter, faq) + BlogWoman-specific (favorites, series, series-detail, video-embed, featured-content) + Blogging (author-bio, related-posts, share-buttons)
### porwoll.de (~15 blocks)
Core (hero, hero-slider, image-slider, text, image-text, card-grid, quote, cta, contact-form, video, divider) + Feature (before-after, testimonials, faq) + Team (team)
### complexcaresolutions.de (~20 blocks)
Core + Content + Team (team, team-filter, services, org-chart) + Feature (locations, logo-grid, stats, jobs, downloads)

16
docs/CHANGELOG.md Normal file
View file

@ -0,0 +1,16 @@
# Changelog
## v1.0.0 (2026-02-14)
### Initial Release
- Type extraction from Payload CMS `payload-types.ts` (12,782 lines → curated exports)
- 39 frontend-relevant collection types
- 42 block type definitions with `BlockByType<T>` helper
- Media type with `getImageUrl()` and `getSrcSet()` helpers
- API response types (`PaginatedResponse<T>`, `PayloadError`, query params)
- Shared API client with tenant isolation (`createPayloadClient()`)
- Block registry with component mapping (`createBlockRenderer()`)
- Tenant constants (porwoll, c2s, gunshin, blogwoman)
- Work order system for CMS→Frontend coordination
- Block catalog documentation

62
package.json Normal file
View file

@ -0,0 +1,62 @@
{
"name": "@c2s/payload-contracts",
"version": "1.0.0",
"description": "Shared TypeScript types, API client, and block registry for Payload CMS frontends",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.js"
},
"./api-client": {
"types": "./dist/api-client/index.d.ts",
"import": "./dist/api-client/index.js"
},
"./blocks": {
"types": "./dist/blocks/registry.d.ts",
"import": "./dist/blocks/registry.js"
},
"./constants": {
"types": "./dist/constants/index.d.ts",
"import": "./dist/constants/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"extract": "tsx scripts/extract-types.ts",
"prepublishOnly": "pnpm build"
},
"keywords": [
"payload-cms",
"types",
"api-client",
"multi-tenant"
],
"license": "UNLICENSED",
"private": true,
"devDependencies": {
"@types/react": "^19.0.0",
"typescript": "^5.7.0",
"tsx": "^4.19.0"
},
"peerDependencies": {
"react": "^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
}

352
pnpm-lock.yaml Normal file
View file

@ -0,0 +1,352 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
react:
specifier: ^19.0.0
version: 19.2.4
devDependencies:
'@types/react':
specifier: ^19.0.0
version: 19.2.14
tsx:
specifier: ^4.19.0
version: 4.21.0
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
snapshots:
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@types/react@19.2.14':
dependencies:
csstype: 3.2.3
csstype@3.2.3: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
react@19.2.4: {}
resolve-pkg-maps@1.0.0: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.3
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}

316
scripts/extract-types.ts Normal file
View file

@ -0,0 +1,316 @@
#!/usr/bin/env tsx
/**
* extract-types.ts
*
* Copies payload-types.ts from the CMS repo into the contracts package
* and generates curated re-export modules for frontend consumption.
*
* Usage: tsx scripts/extract-types.ts [path-to-payload-types.ts]
* Default: ../payload-cms/src/payload-types.ts
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const TYPES_DIR = resolve(ROOT, 'src/types')
const sourcePath = process.argv[2] || resolve(ROOT, '../payload-cms/src/payload-types.ts')
console.log(`[extract] Reading types from: ${sourcePath}`)
const source = readFileSync(sourcePath, 'utf-8')
// Ensure output directory exists
mkdirSync(TYPES_DIR, { recursive: true })
// 1. Copy full payload-types.ts
const header = `/* Auto-extracted from Payload CMS — DO NOT EDIT MANUALLY */
/* Re-run: pnpm extract */
/* Source: payload-cms/src/payload-types.ts */\n\n`
// Strip the `declare module 'payload'` augmentation — frontends don't have payload installed
const cleanedSource = source.replace(/\s*declare module 'payload' \{[\s\S]*?\}\s*$/, '\n')
writeFileSync(resolve(TYPES_DIR, 'payload-types.ts'), header + cleanedSource)
console.log('[extract] Copied payload-types.ts')
// 2. Extract interface names from the Config.collections mapping
const collectionsMatch = source.match(/collections:\s*\{([^}]+)\}/s)
if (!collectionsMatch) {
console.error('[extract] Could not find collections in Config interface')
process.exit(1)
}
// Parse lines like " users: User;" or " 'social-links': SocialLink;"
const collectionEntries = [...collectionsMatch[1].matchAll(/^\s*'?([a-z][\w-]*)'?\s*:\s*(\w+)\s*;/gm)]
.map(m => ({ slug: m[1], typeName: m[2] }))
console.log(`[extract] Found ${collectionEntries.length} collections`)
// Define which collections are frontend-relevant (exclude system/admin-only)
const SYSTEM_COLLECTIONS = new Set([
'payload-kv', 'payload-locked-documents', 'payload-preferences', 'payload-migrations',
'email-logs', 'audit-logs', 'consent-logs', 'form-submissions', 'redirects',
'social-accounts', 'social-platforms', 'community-interactions', 'community-templates',
'community-rules', 'report-schedules', 'youtube-channels', 'youtube-content',
'yt-tasks', 'yt-notifications', 'yt-batches', 'yt-monthly-goals',
'yt-script-templates', 'yt-checklist-templates', 'yt-series',
])
const frontendCollections = collectionEntries.filter(c => !SYSTEM_COLLECTIONS.has(c.slug))
const systemCollections = collectionEntries.filter(c => SYSTEM_COLLECTIONS.has(c.slug))
console.log(`[extract] Frontend collections: ${frontendCollections.length}`)
console.log(`[extract] System collections (excluded): ${systemCollections.length}`)
// 3. Generate collections.ts — re-exports of frontend-relevant collection types
const collectionsFile = `/**
* Frontend-relevant collection types
* Auto-generated by extract-types.ts DO NOT EDIT MANUALLY
*/
import type {
${frontendCollections.map(c => ` ${c.typeName},`).join('\n')}
} from './payload-types'
// Re-export all types
export type {
${frontendCollections.map(c => ` ${c.typeName},`).join('\n')}
} from './payload-types'
/**
* Collection slug to type mapping for frontend use
*/
export interface CollectionTypeMap {
${frontendCollections.map(c => ` '${c.slug}': ${c.typeName};`).join('\n')}
}
export type CollectionSlug = keyof CollectionTypeMap
`
writeFileSync(resolve(TYPES_DIR, 'collections.ts'), collectionsFile)
console.log('[extract] Generated collections.ts')
// 4. Extract block types from the Page interface
// Blocks are defined as a union within the Page.layout array
const blockTypes: string[] = []
const blockTypeRegex = /blockType:\s*'([^']+)'/g
let match: RegExpExecArray | null
// Only scan the Page interface section (roughly lines 519-3070)
const pageSection = source.substring(
source.indexOf('export interface Page'),
source.indexOf('export interface Video')
)
while ((match = blockTypeRegex.exec(pageSection)) !== null) {
if (!blockTypes.includes(match[1])) {
blockTypes.push(match[1])
}
}
console.log(`[extract] Found ${blockTypes.length} block types`)
// 5. Generate blocks.ts
const blocksFile = `/**
* Block type definitions
* Auto-generated by extract-types.ts DO NOT EDIT MANUALLY
*/
export type { Page } from './payload-types'
/**
* Union of all page block types (extracted from Page.layout)
* Each block has a discriminant 'blockType' field.
*
* Usage:
* import type { Block } from '@c2s/payload-contracts/types'
* if (block.blockType === 'hero-block') { ... }
*/
import type { Page } from './payload-types'
export type Block = NonNullable<Page['layout']>[number]
/**
* Extract a specific block type by its blockType discriminant
*
* Usage:
* type HeroBlock = BlockByType<'hero-block'>
*/
export type BlockByType<T extends Block['blockType']> = Extract<Block, { blockType: T }>
/**
* All block type slugs as a const array
*/
export const BLOCK_TYPES = [
${blockTypes.map(b => ` '${b}',`).join('\n')}
] as const
export type BlockType = typeof BLOCK_TYPES[number]
`
writeFileSync(resolve(TYPES_DIR, 'blocks.ts'), blocksFile)
console.log('[extract] Generated blocks.ts')
// 6. Generate media.ts — dedicated Media type with image sizes documentation
const mediaFile = `/**
* Media type with responsive image sizes
* Auto-generated by extract-types.ts DO NOT EDIT MANUALLY
*/
export type { Media } from './payload-types'
import type { Media } from './payload-types'
/**
* All available image size names
*
* Standard sizes: thumbnail, small, medium, large, xlarge, 2k, og
* AVIF sizes: medium_avif, large_avif, xlarge_avif
*/
export type ImageSizeName = keyof NonNullable<Media['sizes']>
/**
* A single image size entry
*/
export type ImageSize = NonNullable<NonNullable<Media['sizes']>[ImageSizeName]>
/**
* Helper to get the URL of a specific size, with fallback
*/
export function getImageUrl(media: Media | null | undefined, size: ImageSizeName = 'large'): string | null {
if (!media) return null
const sizeData = media.sizes?.[size]
return sizeData?.url || media.url || null
}
/**
* Get srcSet string for responsive images
*/
export function getSrcSet(media: Media | null | undefined, sizes: ImageSizeName[] = ['small', 'medium', 'large', 'xlarge']): string {
if (!media?.sizes) return ''
return sizes
.map(size => {
const s = media.sizes?.[size]
if (!s?.url || !s?.width) return null
return \`\${s.url} \${s.width}w\`
})
.filter(Boolean)
.join(', ')
}
`
writeFileSync(resolve(TYPES_DIR, 'media.ts'), mediaFile)
console.log('[extract] Generated media.ts')
// 7. Generate api.ts — API response types that Payload returns
const apiFile = `/**
* Payload CMS REST API response types
* These match the actual JSON responses from the Payload REST API.
*/
/**
* Paginated collection response
* Returned by: GET /api/{collection}
*/
export interface PaginatedResponse<T> {
docs: T[]
totalDocs: number
limit: number
totalPages: number
page: number
pagingCounter: number
hasPrevPage: boolean
hasNextPage: boolean
prevPage: number | null
nextPage: number | null
}
/**
* Single document response
* Returned by: GET /api/{collection}/{id}
*/
export type SingleResponse<T> = T
/**
* Error response from Payload API
*/
export interface PayloadError {
errors: Array<{
message: string
name?: string
data?: Record<string, unknown>
}>
}
/**
* Query parameters for collection requests
*/
export interface CollectionQueryParams {
/** Depth of relationship population (0-10) */
depth?: number
/** Locale for localized fields */
locale?: 'de' | 'en'
/** Fallback locale when field is empty */
fallbackLocale?: 'de' | 'en' | 'none'
/** Number of results per page */
limit?: number
/** Page number */
page?: number
/** Sort field (prefix with - for descending) */
sort?: string
/** Payload query filter (where clause) */
where?: Record<string, unknown>
/** Draft mode */
draft?: boolean
}
/**
* Query parameters for global requests
*/
export interface GlobalQueryParams {
depth?: number
locale?: 'de' | 'en'
fallbackLocale?: 'de' | 'en' | 'none'
draft?: boolean
}
/**
* Available locales in this Payload instance
*/
export type Locale = 'de' | 'en'
export const DEFAULT_LOCALE: Locale = 'de'
`
writeFileSync(resolve(TYPES_DIR, 'api.ts'), apiFile)
console.log('[extract] Generated api.ts')
// 8. Generate index.ts
const indexFile = `/**
* @c2s/payload-contracts Type definitions
*
* Re-exports curated types for frontend consumption.
* Full Payload types available via './payload-types' if needed.
*/
// Collection types (frontend-relevant)
export * from './collections'
// Block types
export * from './blocks'
// Media with helpers
export * from './media'
// API response types
export * from './api'
`
writeFileSync(resolve(TYPES_DIR, 'index.ts'), indexFile)
console.log('[extract] Generated index.ts')
console.log('\n[extract] Done! Generated files in src/types/:')
console.log(' - payload-types.ts (full copy from CMS)')
console.log(' - collections.ts (frontend collection re-exports)')
console.log(' - blocks.ts (block type union + helpers)')
console.log(' - media.ts (media type + image helpers)')
console.log(' - api.ts (API response types)')
console.log(' - index.ts (barrel export)')

214
src/api-client/client.ts Normal file
View file

@ -0,0 +1,214 @@
/**
* Core Payload CMS API client
*
* A generic fetch wrapper with tenant isolation, error handling,
* and Next.js ISR support. All API calls are scoped to a single tenant.
*/
import type { PaginatedResponse, CollectionQueryParams, GlobalQueryParams, PayloadError } from '../types/api'
export interface PayloadClientConfig {
/** Base URL of the Payload CMS instance (e.g. https://cms.c2sgmbh.de) */
baseUrl: string
/** Tenant ID for multi-tenant isolation (will be converted to string) */
tenantId: string | number
/** Default locale for all requests */
defaultLocale?: 'de' | 'en'
/** Default depth for relationship population */
defaultDepth?: number
/** Default revalidation time in seconds (Next.js ISR) */
defaultRevalidate?: number
/** Custom fetch function (defaults to global fetch) */
fetchFn?: typeof fetch
}
interface InternalConfig {
baseUrl: string
tenantId: string
defaultLocale: 'de' | 'en'
defaultDepth: number
defaultRevalidate: number
fetchFn: typeof fetch
}
export class PayloadClient {
private config: InternalConfig
constructor(config: PayloadClientConfig) {
this.config = {
baseUrl: config.baseUrl.replace(/\/$/, ''),
tenantId: String(config.tenantId),
defaultLocale: config.defaultLocale ?? 'de',
defaultDepth: config.defaultDepth ?? 2,
defaultRevalidate: config.defaultRevalidate ?? 60,
fetchFn: config.fetchFn ?? globalThis.fetch,
}
}
/**
* Generic fetch wrapper with tenant isolation and error handling
*/
async fetch<T>(
endpoint: string,
options?: {
params?: URLSearchParams
revalidate?: number
tags?: string[]
cache?: RequestCache
}
): Promise<T> {
const url = new URL(`${this.config.baseUrl}${endpoint}`)
if (options?.params) {
options.params.forEach((value, key) => url.searchParams.set(key, value))
}
const fetchOptions: RequestInit & { next?: { revalidate?: number; tags?: string[] } } = {
headers: { 'Content-Type': 'application/json' },
}
if (options?.cache) {
fetchOptions.cache = options.cache
} else if (options?.revalidate !== undefined || options?.tags) {
fetchOptions.next = {
revalidate: options.revalidate ?? this.config.defaultRevalidate,
tags: options.tags,
}
}
const res = await this.config.fetchFn(url.toString(), fetchOptions)
if (!res.ok) {
let errorBody: PayloadError | null = null
try {
errorBody = await res.json() as PayloadError
} catch {
// Response body is not JSON
}
const message = errorBody?.errors?.[0]?.message ?? `Payload API error: ${res.status} ${res.statusText}`
throw new PayloadAPIError(message, res.status, errorBody)
}
return res.json() as Promise<T>
}
/**
* Fetch a paginated collection with tenant isolation
*/
async getCollection<T>(
collection: string,
query?: CollectionQueryParams & { extraWhere?: Record<string, unknown> }
): Promise<PaginatedResponse<T>> {
const params = new URLSearchParams()
// Tenant isolation — always applied
params.set('where[tenant][equals]', this.config.tenantId)
// Standard query params
params.set('locale', query?.locale ?? this.config.defaultLocale)
params.set('depth', String(query?.depth ?? this.config.defaultDepth))
if (query?.limit) params.set('limit', String(query.limit))
if (query?.page) params.set('page', String(query.page))
if (query?.sort) params.set('sort', query.sort)
if (query?.draft) params.set('draft', 'true')
if (query?.fallbackLocale) params.set('fallbackLocale', query.fallbackLocale)
// Additional where clauses
if (query?.where) {
for (const [key, value] of Object.entries(query.where)) {
params.set(`where[${key}]`, String(value))
}
}
if (query?.extraWhere) {
for (const [key, value] of Object.entries(query.extraWhere)) {
params.set(`where[${key}]`, String(value))
}
}
return this.fetch<PaginatedResponse<T>>(`/api/${collection}`, {
params,
tags: [collection],
})
}
/**
* Fetch a single document by ID
*/
async getById<T>(
collection: string,
id: number | string,
query?: Pick<CollectionQueryParams, 'depth' | 'locale' | 'draft'>
): Promise<T> {
const params = new URLSearchParams()
params.set('locale', query?.locale ?? this.config.defaultLocale)
params.set('depth', String(query?.depth ?? this.config.defaultDepth))
if (query?.draft) params.set('draft', 'true')
return this.fetch<T>(`/api/${collection}/${id}`, {
params,
tags: [collection, `${collection}-${id}`],
})
}
/**
* Fetch a global (e.g. seo-settings)
*/
async getGlobal<T>(
slug: string,
query?: GlobalQueryParams
): Promise<T> {
const params = new URLSearchParams()
params.set('locale', query?.locale ?? this.config.defaultLocale)
params.set('depth', String(query?.depth ?? this.config.defaultDepth))
if (query?.draft) params.set('draft', 'true')
return this.fetch<T>(`/api/globals/${slug}`, {
params,
tags: [`global-${slug}`],
})
}
/**
* POST request (for form submissions, newsletter subscriptions, etc.)
*/
async post<T>(endpoint: string, body: Record<string, unknown>): Promise<T> {
const url = `${this.config.baseUrl}${endpoint}`
const res = await this.config.fetchFn(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
})
if (!res.ok) {
let errorBody: PayloadError | null = null
try {
errorBody = await res.json() as PayloadError
} catch {
// Response body is not JSON
}
const message = errorBody?.errors?.[0]?.message ?? `Payload API error: ${res.status} ${res.statusText}`
throw new PayloadAPIError(message, res.status, errorBody)
}
return res.json() as Promise<T>
}
/** Expose config for downstream use */
get tenantId(): string { return this.config.tenantId }
get baseUrl(): string { return this.config.baseUrl }
get defaultLocale(): 'de' | 'en' { return this.config.defaultLocale }
}
/**
* Typed error class for Payload API failures
*/
export class PayloadAPIError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly body: PayloadError | null
) {
super(message)
this.name = 'PayloadAPIError'
}
}

78
src/api-client/index.ts Normal file
View file

@ -0,0 +1,78 @@
/**
* Payload CMS API Client Factory
*
* Usage:
* import { createPayloadClient } from '@c2s/payload-contracts/api-client'
*
* const cms = createPayloadClient({
* baseUrl: process.env.NEXT_PUBLIC_PAYLOAD_URL!,
* tenantId: process.env.NEXT_PUBLIC_TENANT_ID!,
* })
*
* const page = await cms.pages.getPage('home')
* const posts = await cms.posts.getPosts({ limit: 10 })
*/
import { PayloadClient, type PayloadClientConfig } from './client'
import { createPageApi } from './pages'
import { createPostApi } from './posts'
import { createNavigationApi } from './navigation'
import { createSettingsApi } from './settings'
export type { PayloadClientConfig } from './client'
export { PayloadClient, PayloadAPIError } from './client'
export function createPayloadClient(config: PayloadClientConfig) {
const client = new PayloadClient(config)
return {
/** Raw client for custom queries */
client,
/** Page queries */
pages: createPageApi(client),
/** Post, category, tag, and author queries */
posts: createPostApi(client),
/** Navigation queries */
navigation: createNavigationApi(client),
/** Site settings, SEO, cookie config */
settings: createSettingsApi(client),
/**
* Generic collection query use for collections not covered above
*
* Example:
* const faqs = await cms.collection<Faq>('faqs', { limit: 50 })
*/
collection: client.getCollection.bind(client),
/**
* Fetch a single document by ID
*
* Example:
* const post = await cms.byId<Post>('posts', 42)
*/
byId: client.getById.bind(client),
/**
* Fetch a global document
*
* Example:
* const seo = await cms.global('seo-settings')
*/
global: client.getGlobal.bind(client),
/**
* POST request (forms, newsletter, etc.)
*
* Example:
* await cms.post('/api/newsletter/subscribe', { email: '...' })
*/
post: client.post.bind(client),
}
}
export type PayloadCMS = ReturnType<typeof createPayloadClient>

View file

@ -0,0 +1,29 @@
/**
* Navigation API functions
*/
import type { Navigation } from '../types/collections'
import type { PaginatedResponse, CollectionQueryParams } from '../types/api'
import type { PayloadClient } from './client'
export function createNavigationApi(client: PayloadClient) {
return {
/**
* Get navigation by type (header, footer, mobile, etc.)
*/
async getNavigation(type: string, options?: Pick<CollectionQueryParams, 'locale' | 'depth'>): Promise<Navigation | null> {
const result = await client.getCollection<Navigation>('navigations', {
...options,
where: { 'type][equals': type },
limit: 1,
})
return result.docs[0] ?? null
},
/**
* Get all navigations
*/
async getNavigations(options?: CollectionQueryParams): Promise<PaginatedResponse<Navigation>> {
return client.getCollection<Navigation>('navigations', options)
},
}
}

39
src/api-client/pages.ts Normal file
View file

@ -0,0 +1,39 @@
/**
* Page API functions
*/
import type { Page } from '../types/collections'
import type { PaginatedResponse, CollectionQueryParams } from '../types/api'
import type { PayloadClient } from './client'
export function createPageApi(client: PayloadClient) {
return {
/**
* Get a single page by slug
*/
async getPage(slug: string, options?: Pick<CollectionQueryParams, 'locale' | 'depth' | 'draft'>): Promise<Page | null> {
const result = await client.getCollection<Page>('pages', {
...options,
depth: options?.depth ?? 3,
where: {
'slug][equals': slug,
'status][equals': 'published',
},
limit: 1,
})
return result.docs[0] ?? null
},
/**
* Get all pages (for sitemap, navigation, etc.)
*/
async getPages(options?: CollectionQueryParams): Promise<PaginatedResponse<Page>> {
return client.getCollection<Page>('pages', {
...options,
where: {
'status][equals': 'published',
...options?.where,
},
})
},
}
}

95
src/api-client/posts.ts Normal file
View file

@ -0,0 +1,95 @@
/**
* Post API functions
*/
import type { Post, Category, Tag, Author } from '../types/collections'
import type { PaginatedResponse, CollectionQueryParams } from '../types/api'
import type { PayloadClient } from './client'
export interface PostQueryOptions extends CollectionQueryParams {
/** Filter by post type (blog, news, press) */
type?: string
/** Filter by category ID */
category?: number | string
/** Filter by tag ID */
tag?: number | string
/** Filter by author ID */
author?: number | string
/** Filter by series ID */
series?: number | string
/** Exclude a specific post (useful for "related posts") */
excludeId?: number
}
export function createPostApi(client: PayloadClient) {
return {
/**
* Get posts with optional filtering
*/
async getPosts(options?: PostQueryOptions): Promise<PaginatedResponse<Post>> {
const where: Record<string, unknown> = {
'status][equals': 'published',
}
if (options?.type) where['type][equals'] = options.type
if (options?.category) where['categories][equals'] = options.category
if (options?.tag) where['tags][equals'] = options.tag
if (options?.author) where['author][equals'] = options.author
if (options?.series) where['series][equals'] = options.series
if (options?.excludeId) where['id][not_equals'] = options.excludeId
return client.getCollection<Post>('posts', {
...options,
sort: options?.sort ?? '-publishedAt',
where: { ...where, ...options?.where },
})
},
/**
* Get a single post by slug
*/
async getPost(slug: string, options?: Pick<CollectionQueryParams, 'locale' | 'depth' | 'draft'>): Promise<Post | null> {
const result = await client.getCollection<Post>('posts', {
...options,
depth: options?.depth ?? 3,
where: {
'slug][equals': slug,
'status][equals': 'published',
},
limit: 1,
})
return result.docs[0] ?? null
},
/**
* Get all categories
*/
async getCategories(options?: CollectionQueryParams): Promise<PaginatedResponse<Category>> {
return client.getCollection<Category>('categories', {
limit: 100,
sort: 'title',
...options,
})
},
/**
* Get all tags
*/
async getTags(options?: CollectionQueryParams): Promise<PaginatedResponse<Tag>> {
return client.getCollection<Tag>('tags', {
limit: 100,
sort: 'name',
...options,
})
},
/**
* Get all authors
*/
async getAuthors(options?: CollectionQueryParams): Promise<PaginatedResponse<Author>> {
return client.getCollection<Author>('authors', {
limit: 50,
sort: 'name',
...options,
})
},
}
}

View file

@ -0,0 +1,49 @@
/**
* Settings & Globals API functions
*/
import type { SiteSetting, CookieConfiguration } from '../types/collections'
import type { PaginatedResponse, CollectionQueryParams, GlobalQueryParams } from '../types/api'
import type { PayloadClient } from './client'
export function createSettingsApi(client: PayloadClient) {
return {
/**
* Get site settings for the current tenant
*/
async getSiteSettings(options?: Pick<CollectionQueryParams, 'locale' | 'depth'>): Promise<SiteSetting | null> {
const result = await client.getCollection<SiteSetting>('site-settings', {
...options,
limit: 1,
})
return result.docs[0] ?? null
},
/**
* Get SEO global settings
*/
async getSeoSettings(options?: GlobalQueryParams): Promise<Record<string, unknown>> {
return client.getGlobal('seo-settings', options)
},
/**
* Get cookie configuration for the current tenant
*/
async getCookieConfiguration(options?: Pick<CollectionQueryParams, 'locale'>): Promise<CookieConfiguration | null> {
const result = await client.getCollection<CookieConfiguration>('cookie-configurations', {
...options,
limit: 1,
})
return result.docs[0] ?? null
},
/**
* Get privacy policy settings for the current tenant
*/
async getPrivacyPolicySettings(options?: Pick<CollectionQueryParams, 'locale'>): Promise<PaginatedResponse<unknown>> {
return client.getCollection('privacy-policy-settings', {
...options,
limit: 1,
})
},
}
}

71
src/blocks/registry.tsx Normal file
View file

@ -0,0 +1,71 @@
/**
* Block Registry
*
* Provides a type-safe way to map CMS block types to React components.
* Each frontend registers only the blocks it needs.
*
* Usage:
* import { createBlockRenderer } from '@c2s/payload-contracts/blocks'
* import { HeroBlock } from './blocks/HeroBlock'
* import { TextBlock } from './blocks/TextBlock'
*
* const BlockRenderer = createBlockRenderer({
* 'hero-block': HeroBlock,
* 'text-block': TextBlock,
* })
*
* // In a page component:
* <BlockRenderer blocks={page.layout} />
*/
import type { Block, BlockType } from '../types/blocks'
/** Props that a block component receives (block data minus blockType) */
export type BlockComponentProps<T extends Block = Block> = Omit<T, 'blockType'>
/** A React component that renders a specific block type */
export type BlockComponent<T extends Block = Block> = React.ComponentType<BlockComponentProps<T>>
/** Map of block type slugs to their React components */
export type BlockComponentMap = Partial<Record<BlockType, BlockComponent<any>>>
export interface BlockRendererProps {
/** Array of blocks from a Payload CMS page layout */
blocks: Block[] | null | undefined
/** Optional className for the wrapper */
className?: string
}
/**
* Create a block renderer component from a map of block components.
*
* Unregistered blocks are silently skipped in production
* and logged as warnings in development.
*/
export function createBlockRenderer(components: BlockComponentMap) {
function BlockRenderer({ blocks, className }: BlockRendererProps) {
if (!blocks?.length) return null
const elements = blocks.map((block, index) => {
const Component = components[block.blockType]
if (!Component) {
return null
}
const { blockType, ...props } = block
const key = ('id' in block && block.id) ? String(block.id) : `${blockType}-${index}`
return <Component key={key} {...(props as any)} />
})
if (className) {
return <div className={className}>{elements}</div>
}
return <>{elements}</>
}
BlockRenderer.displayName = 'BlockRenderer'
return BlockRenderer
}

1
src/constants/index.ts Normal file
View file

@ -0,0 +1 @@
export { TENANTS, type TenantSlug, type TenantConfig } from './tenants'

15
src/constants/tenants.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Tenant configuration constants
*
* These IDs must match the Payload CMS tenant records.
*/
export const TENANTS = {
porwoll: { id: 1, slug: 'porwoll', name: 'porwoll.de' },
c2s: { id: 4, slug: 'c2s', name: 'Complex Care Solutions GmbH' },
gunshin: { id: 5, slug: 'gunshin', name: 'Gunshin' },
blogwoman: { id: 9, slug: 'blogwoman', name: 'BlogWoman.de' },
} as const
export type TenantSlug = keyof typeof TENANTS
export type TenantConfig = typeof TENANTS[TenantSlug]

17
src/index.ts Normal file
View file

@ -0,0 +1,17 @@
/**
* @c2s/payload-contracts
*
* Shared TypeScript types, API client, and block registry
* for Payload CMS multi-tenant frontends.
*
* Subpath exports:
* @c2s/payload-contracts/types Type definitions
* @c2s/payload-contracts/api-client API client factory
* @c2s/payload-contracts/blocks Block registry
* @c2s/payload-contracts/constants Tenant constants
*/
export * from './types'
export { createPayloadClient, PayloadClient, PayloadAPIError, type PayloadClientConfig, type PayloadCMS } from './api-client'
export { createBlockRenderer, type BlockComponentMap, type BlockComponent, type BlockComponentProps, type BlockRendererProps } from './blocks/registry'
export { TENANTS, type TenantSlug, type TenantConfig } from './constants'

76
src/types/api.ts Normal file
View file

@ -0,0 +1,76 @@
/**
* Payload CMS REST API response types
* These match the actual JSON responses from the Payload REST API.
*/
/**
* Paginated collection response
* Returned by: GET /api/{collection}
*/
export interface PaginatedResponse<T> {
docs: T[]
totalDocs: number
limit: number
totalPages: number
page: number
pagingCounter: number
hasPrevPage: boolean
hasNextPage: boolean
prevPage: number | null
nextPage: number | null
}
/**
* Single document response
* Returned by: GET /api/{collection}/{id}
*/
export type SingleResponse<T> = T
/**
* Error response from Payload API
*/
export interface PayloadError {
errors: Array<{
message: string
name?: string
data?: Record<string, unknown>
}>
}
/**
* Query parameters for collection requests
*/
export interface CollectionQueryParams {
/** Depth of relationship population (0-10) */
depth?: number
/** Locale for localized fields */
locale?: 'de' | 'en'
/** Fallback locale when field is empty */
fallbackLocale?: 'de' | 'en' | 'none'
/** Number of results per page */
limit?: number
/** Page number */
page?: number
/** Sort field (prefix with - for descending) */
sort?: string
/** Payload query filter (where clause) */
where?: Record<string, unknown>
/** Draft mode */
draft?: boolean
}
/**
* Query parameters for global requests
*/
export interface GlobalQueryParams {
depth?: number
locale?: 'de' | 'en'
fallbackLocale?: 'de' | 'en' | 'none'
draft?: boolean
}
/**
* Available locales in this Payload instance
*/
export type Locale = 'de' | 'en'
export const DEFAULT_LOCALE: Locale = 'de'

75
src/types/blocks.ts Normal file
View file

@ -0,0 +1,75 @@
/**
* Block type definitions
* Auto-generated by extract-types.ts DO NOT EDIT MANUALLY
*/
export type { Page } from './payload-types'
/**
* Union of all page block types (extracted from Page.layout)
* Each block has a discriminant 'blockType' field.
*
* Usage:
* import type { Block } from '@c2s/payload-contracts/types'
* if (block.blockType === 'hero-block') { ... }
*/
import type { Page } from './payload-types'
export type Block = NonNullable<Page['layout']>[number]
/**
* Extract a specific block type by its blockType discriminant
*
* Usage:
* type HeroBlock = BlockByType<'hero-block'>
*/
export type BlockByType<T extends Block['blockType']> = Extract<Block, { blockType: T }>
/**
* All block type slugs as a const array
*/
export const BLOCK_TYPES = [
'hero-block',
'hero-slider-block',
'image-slider-block',
'text-block',
'image-text-block',
'card-grid-block',
'quote-block',
'cta-block',
'contact-form-block',
'timeline-block',
'divider-block',
'video-block',
'posts-list-block',
'testimonials-block',
'newsletter-block',
'process-steps-block',
'faq-block',
'team-block',
'services-block',
'author-bio-block',
'related-posts-block',
'share-buttons-block',
'toc-block',
'team-filter-block',
'org-chart-block',
'locations-block',
'logo-grid-block',
'stats-block',
'jobs-block',
'downloads-block',
'map-block',
'events',
'pricing',
'tabs',
'accordion',
'comparison',
'before-after',
'favorites-block',
'series-block',
'series-detail-block',
'video-embed-block',
'featured-content-block',
] as const
export type BlockType = typeof BLOCK_TYPES[number]

135
src/types/collections.ts Normal file
View file

@ -0,0 +1,135 @@
/**
* Frontend-relevant collection types
* Auto-generated by extract-types.ts DO NOT EDIT MANUALLY
*/
import type {
User,
Media,
Tenant,
Page,
Post,
Category,
SocialLink,
Testimonial,
Faq,
Team,
ServiceCategory,
Service,
NewsletterSubscriber,
PortfolioCategory,
Portfolio,
VideoCategory,
Video,
ProductCategory,
Product,
Timeline,
Workflow,
Tag,
Author,
Location,
Partner,
Job,
Download,
Event,
Booking,
Certification,
Project,
Favorite,
Series,
CookieConfiguration,
CookieInventory,
PrivacyPolicySetting,
SiteSetting,
Navigation,
Form,
} from './payload-types'
// Re-export all types
export type {
User,
Media,
Tenant,
Page,
Post,
Category,
SocialLink,
Testimonial,
Faq,
Team,
ServiceCategory,
Service,
NewsletterSubscriber,
PortfolioCategory,
Portfolio,
VideoCategory,
Video,
ProductCategory,
Product,
Timeline,
Workflow,
Tag,
Author,
Location,
Partner,
Job,
Download,
Event,
Booking,
Certification,
Project,
Favorite,
Series,
CookieConfiguration,
CookieInventory,
PrivacyPolicySetting,
SiteSetting,
Navigation,
Form,
} from './payload-types'
/**
* Collection slug to type mapping for frontend use
*/
export interface CollectionTypeMap {
'users': User;
'media': Media;
'tenants': Tenant;
'pages': Page;
'posts': Post;
'categories': Category;
'social-links': SocialLink;
'testimonials': Testimonial;
'faqs': Faq;
'team': Team;
'service-categories': ServiceCategory;
'services': Service;
'newsletter-subscribers': NewsletterSubscriber;
'portfolio-categories': PortfolioCategory;
'portfolios': Portfolio;
'video-categories': VideoCategory;
'videos': Video;
'product-categories': ProductCategory;
'products': Product;
'timelines': Timeline;
'workflows': Workflow;
'tags': Tag;
'authors': Author;
'locations': Location;
'partners': Partner;
'jobs': Job;
'downloads': Download;
'events': Event;
'bookings': Booking;
'certifications': Certification;
'projects': Project;
'favorites': Favorite;
'series': Series;
'cookie-configurations': CookieConfiguration;
'cookie-inventory': CookieInventory;
'privacy-policy-settings': PrivacyPolicySetting;
'site-settings': SiteSetting;
'navigations': Navigation;
'forms': Form;
}
export type CollectionSlug = keyof CollectionTypeMap

18
src/types/index.ts Normal file
View file

@ -0,0 +1,18 @@
/**
* @c2s/payload-contracts Type definitions
*
* Re-exports curated types for frontend consumption.
* Full Payload types available via './payload-types' if needed.
*/
// Collection types (frontend-relevant)
export * from './collections'
// Block types
export * from './blocks'
// Media with helpers
export * from './media'
// API response types
export * from './api'

44
src/types/media.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* Media type with responsive image sizes
* Auto-generated by extract-types.ts DO NOT EDIT MANUALLY
*/
export type { Media } from './payload-types'
import type { Media } from './payload-types'
/**
* All available image size names
*
* Standard sizes: thumbnail, small, medium, large, xlarge, 2k, og
* AVIF sizes: medium_avif, large_avif, xlarge_avif
*/
export type ImageSizeName = keyof NonNullable<Media['sizes']>
/**
* A single image size entry
*/
export type ImageSize = NonNullable<NonNullable<Media['sizes']>[ImageSizeName]>
/**
* Helper to get the URL of a specific size, with fallback
*/
export function getImageUrl(media: Media | null | undefined, size: ImageSizeName = 'large'): string | null {
if (!media) return null
const sizeData = media.sizes?.[size]
return sizeData?.url || media.url || null
}
/**
* Get srcSet string for responsive images
*/
export function getSrcSet(media: Media | null | undefined, sizes: ImageSizeName[] = ['small', 'medium', 'large', 'xlarge']): string {
if (!media?.sizes) return ''
return sizes
.map(size => {
const s = media.sizes?.[size]
if (!s?.url || !s?.width) return null
return `${s.url} ${s.width}w`
})
.filter(Boolean)
.join(', ')
}

12782
src/types/payload-types.ts Normal file

File diff suppressed because it is too large Load diff

22
tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM"],
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist", "scripts"]
}

49
work-orders/_template.md Normal file
View file

@ -0,0 +1,49 @@
# Work Order: [Titel]
## Status
- [ ] Erstellt
- [ ] In Bearbeitung
- [ ] Verifiziert
- [ ] Abgeschlossen
## Auslöser
- **CMS-Commit:** [hash] ([Beschreibung])
- **Contracts-Version:** [version / commit hash]
- **Datum:** YYYY-MM-DD
## Änderung
[Was im CMS geändert wurde — neuer Block, geändertes Feld, neue Collection, etc.]
## TypeScript-Interface
```typescript
// Exaktes Interface für den neuen/geänderten Typ
```
## Implementierungsanweisungen
### Voraussetzungen
1. `pnpm update @c2s/payload-contracts` (oder `pnpm install` bei Git-Dependency)
### Schritte
1. [Spezifische Implementierungsschritte]
2. [...]
3. `pnpm build && pnpm lint` — Verify
### API-Aufruf
```typescript
// Beispiel-Code wie die Daten geholt werden
```
## Betroffene Frontends
- [ ] frontend.blogwoman.de
- [ ] frontend.porwoll.de
- [ ] frontend.complexcaresolutions.de
- [ ] frontend.caroline-porwoll.com
- [ ] frontend.caroline-porwoll.de
- [ ] frontend.gunshin.de
## Referenz
[Link zu bestehendem Block/Code als Muster, z.B. "Analog zu hero-block implementiert"]
## Notizen
[Besonderheiten, Breaking Changes, Abhängigkeiten]

View file