mirror of
https://github.com/complexcaresolutions/payload-contracts.git
synced 2026-03-17 15:13:48 +00:00
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:
commit
774d7bc402
26 changed files with 14762 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
71
CLAUDE.md
Normal file
71
CLAUDE.md
Normal 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
131
docs/BLOCK_CATALOG.md
Normal 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
16
docs/CHANGELOG.md
Normal 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
62
package.json
Normal 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
352
pnpm-lock.yaml
Normal 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
316
scripts/extract-types.ts
Normal 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
214
src/api-client/client.ts
Normal 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
78
src/api-client/index.ts
Normal 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>
|
||||
29
src/api-client/navigation.ts
Normal file
29
src/api-client/navigation.ts
Normal 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
39
src/api-client/pages.ts
Normal 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
95
src/api-client/posts.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
49
src/api-client/settings.ts
Normal file
49
src/api-client/settings.ts
Normal 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
71
src/blocks/registry.tsx
Normal 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
1
src/constants/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { TENANTS, type TenantSlug, type TenantConfig } from './tenants'
|
||||
15
src/constants/tenants.ts
Normal file
15
src/constants/tenants.ts
Normal 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
17
src/index.ts
Normal 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
76
src/types/api.ts
Normal 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
75
src/types/blocks.ts
Normal 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
135
src/types/collections.ts
Normal 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
18
src/types/index.ts
Normal 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
44
src/types/media.ts
Normal 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
12782
src/types/payload-types.ts
Normal file
File diff suppressed because it is too large
Load diff
22
tsconfig.json
Normal file
22
tsconfig.json
Normal 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
49
work-orders/_template.md
Normal 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]
|
||||
0
work-orders/completed/.gitkeep
Normal file
0
work-orders/completed/.gitkeep
Normal file
Loading…
Reference in a new issue