feat: migrate API layer to @c2s/payload-contracts

- Add @c2s/payload-contracts as shared API client dependency
- Create src/lib/cms.ts with tenant-configured PayloadClient instance
- Replace manual fetch logic in api.ts with contracts client calls
- Add transpilePackages config for TypeScript source imports
- Local types preserved for component compatibility (bridge pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-15 00:44:21 +00:00
parent ba54d7a85d
commit 2500b8b16f
5 changed files with 167 additions and 274 deletions

View file

@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
transpilePackages: ["@c2s/payload-contracts"],
};
export default nextConfig;

View file

@ -13,7 +13,8 @@
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"@c2s/payload-contracts": "github:complexcaresolutions/payload-contracts"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@c2s/payload-contracts':
specifier: github:complexcaresolutions/payload-contracts
version: git+https://git@github.com:complexcaresolutions/payload-contracts.git#64847594b2150bfdce09a7bd7f54ad2f52d6f2b7(react@19.2.1)
clsx:
specifier: ^2.1.1
version: 2.1.1
@ -122,6 +125,15 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
'@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#64847594b2150bfdce09a7bd7f54ad2f52d6f2b7':
resolution: {commit: 64847594b2150bfdce09a7bd7f54ad2f52d6f2b7, repo: git@github.com:complexcaresolutions/payload-contracts.git, type: git}
version: 1.0.0
peerDependencies:
react: ^19.0.0
peerDependenciesMeta:
react:
optional: true
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
@ -2021,6 +2033,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#64847594b2150bfdce09a7bd7f54ad2f52d6f2b7(react@19.2.1)':
optionalDependencies:
react: 19.2.1
'@emnapi/core@1.7.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0

View file

@ -1,80 +1,36 @@
/**
* Payload CMS API functions powered by @c2s/payload-contracts
*
* Uses the shared API client for transport (tenant isolation, fetch caching)
* but returns data typed with local interfaces for component compatibility.
*
* The local types use simplified interfaces (e.g. id: string, meta instead of seo)
* while the contracts use the real CMS types. We use 'as unknown as' to bridge.
*/
import { cms } from "./cms"
import type {
Page,
Post,
Navigation,
SiteSettings,
Favorite,
Series,
Testimonial,
FAQ,
SeoSettings,
PaginatedResponse,
FavoriteCategory,
FavoriteBadge,
} from './types'
SiteSettings,
} from "./types"
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || 'https://cms.c2sgmbh.de'
const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID || '9'
// Shared empty paginated response for error fallbacks
const emptyPaginatedResponse = { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
interface FetchOptions {
revalidate?: number | false
tags?: string[]
}
async function fetchAPI<T>(
endpoint: string,
options: FetchOptions & { defaultValue?: T } = {}
): Promise<T> {
const { revalidate = 60, tags, defaultValue } = options
try {
const res = await fetch(`${PAYLOAD_URL}${endpoint}`, {
next: {
revalidate,
tags,
},
})
if (!res.ok) {
console.error(`API error: ${res.status} ${res.statusText} for ${endpoint}`)
if (defaultValue !== undefined) {
return defaultValue
}
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
return res.json()
} catch (error) {
console.error(`Fetch error for ${endpoint}:`, error)
if (defaultValue !== undefined) {
return defaultValue
}
throw error
}
}
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://cms.c2sgmbh.de"
const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID || "9"
// Pages
export async function getPage(
slug: string,
locale = 'de'
): Promise<Page | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[status][equals]': 'published',
locale,
depth: '2',
export async function getPage(slug: string, locale = "de"): Promise<Page | null> {
const result = await cms.pages.getPage(slug, {
locale: locale as "de" | "en",
depth: 2,
})
const data = await fetchAPI<PaginatedResponse<Page>>(
`/api/pages?${params}`,
{ tags: [`page-${slug}`] }
)
return data.docs[0] || null
return result as unknown as Page | null
}
export async function getPages(options: {
@ -82,41 +38,26 @@ export async function getPages(options: {
page?: number
locale?: string
} = {}): Promise<PaginatedResponse<Page>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[status][equals]': 'published',
limit: String(options.limit || 100),
page: String(options.page || 1),
locale: options.locale || 'de',
depth: '1',
const result = await cms.pages.getPages({
limit: options.limit || 100,
page: options.page || 1,
locale: (options.locale || "de") as "de" | "en",
depth: 1,
})
return fetchAPI<PaginatedResponse<Page>>(`/api/pages?${params}`)
return result as unknown as PaginatedResponse<Page>
}
// Posts
export async function getPost(
slug: string,
locale = 'de'
): Promise<Post | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[status][equals]': 'published',
locale,
depth: '2',
export async function getPost(slug: string, locale = "de"): Promise<Post | null> {
const result = await cms.posts.getPost(slug, {
locale: locale as "de" | "en",
depth: 2,
})
const data = await fetchAPI<PaginatedResponse<Post>>(
`/api/posts?${params}`,
{ tags: [`post-${slug}`] }
)
return data.docs[0] || null
return result as unknown as Post | null
}
export async function getPosts(options: {
type?: 'blog' | 'news' | 'press' | 'announcement'
type?: string
category?: string
series?: string
limit?: number
@ -124,83 +65,53 @@ export async function getPosts(options: {
locale?: string
featured?: boolean
} = {}): Promise<PaginatedResponse<Post>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[status][equals]': 'published',
sort: '-publishedAt',
limit: String(options.limit || 10),
page: String(options.page || 1),
locale: options.locale || 'de',
depth: '1',
})
try {
const where: Record<string, unknown> = {}
if (options.featured) where["isFeatured][equals"] = "true"
if (options.type) {
params.append('where[type][equals]', options.type)
const result = await cms.posts.getPosts({
type: options.type,
category: options.category,
series: options.series,
limit: options.limit || 10,
page: options.page || 1,
locale: (options.locale || "de") as "de" | "en",
where,
})
return result as unknown as PaginatedResponse<Post>
} catch {
return { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
}
if (options.category) {
params.append('where[categories][contains]', options.category)
}
if (options.series) {
params.append('where[series][equals]', options.series)
}
if (options.featured) {
params.append('where[isFeatured][equals]', 'true')
}
return fetchAPI<PaginatedResponse<Post>>(
`/api/posts?${params}`,
{ tags: ['posts'], defaultValue: emptyPaginatedResponse as PaginatedResponse<Post> }
)
}
// Navigation
export async function getNavigation(
type: 'header' | 'footer' | 'mobile'
): Promise<Navigation | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[type][equals]': type,
depth: '2',
})
const data = await fetchAPI<PaginatedResponse<Navigation>>(
`/api/navigations?${params}`,
{
revalidate: 300,
tags: [`navigation-${type}`],
defaultValue: { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null },
}
)
return data.docs[0] || null
export async function getNavigation(type: "header" | "footer" | "mobile"): Promise<Navigation | null> {
try {
const result = await cms.navigation.getNavigation(type, { depth: 2 })
return result as unknown as Navigation | null
} catch {
return null
}
}
// Site Settings
export async function getSiteSettings(): Promise<SiteSettings | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
depth: '2',
})
const data = await fetchAPI<PaginatedResponse<SiteSettings>>(
`/api/site-settings?${params}`,
{
revalidate: 300,
tags: ['site-settings'],
defaultValue: { docs: [], totalDocs: 0, limit: 10, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null },
}
)
return data.docs[0] || null
try {
const result = await cms.settings.getSiteSettings({ depth: 2 })
return result as unknown as SiteSettings | null
} catch {
return null
}
}
// SEO Settings (Global)
export async function getSeoSettings(): Promise<SeoSettings | null> {
return fetchAPI<SeoSettings>('/api/globals/seo-settings', {
revalidate: 3600,
tags: ['seo-settings'],
defaultValue: null as unknown as SeoSettings,
})
try {
const result = await cms.settings.getSeoSettings()
return result as unknown as SeoSettings | null
} catch {
return null
}
}
// Testimonials
@ -208,17 +119,12 @@ export async function getTestimonials(options: {
limit?: number
locale?: string
} = {}): Promise<PaginatedResponse<Testimonial>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
limit: String(options.limit || 10),
locale: options.locale || 'de',
depth: '1',
const result = await cms.client.getCollection("testimonials", {
limit: options.limit || 10,
locale: (options.locale || "de") as "de" | "en",
depth: 1,
})
return fetchAPI<PaginatedResponse<Testimonial>>(
`/api/testimonials?${params}`,
{ tags: ['testimonials'] }
)
return result as unknown as PaginatedResponse<Testimonial>
}
// FAQs
@ -227,72 +133,50 @@ export async function getFAQs(options: {
limit?: number
locale?: string
} = {}): Promise<PaginatedResponse<FAQ>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
sort: 'order',
limit: String(options.limit || 50),
locale: options.locale || 'de',
depth: '1',
const result = await cms.client.getCollection("faqs", {
limit: options.limit || 50,
locale: (options.locale || "de") as "de" | "en",
sort: "order",
depth: 1,
where: options.category ? { "category][equals": options.category } : undefined,
})
if (options.category) {
params.append('where[category][equals]', options.category)
}
return fetchAPI<PaginatedResponse<FAQ>>(
`/api/faqs?${params}`,
{ tags: ['faqs'] }
)
return result as unknown as PaginatedResponse<FAQ>
}
// BlogWoman: Favorites
export async function getFavorites(options: {
category?: FavoriteCategory
badge?: FavoriteBadge
category?: string
badge?: string
limit?: number
page?: number
locale?: string
} = {}): Promise<PaginatedResponse<Favorite>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[isActive][equals]': 'true',
limit: String(options.limit || 12),
page: String(options.page || 1),
locale: options.locale || 'de',
depth: '1',
})
try {
const where: Record<string, unknown> = { "isActive][equals": "true" }
if (options.category) where["category][equals"] = options.category
if (options.badge) where["badge][equals"] = options.badge
if (options.category) {
params.append('where[category][equals]', options.category)
const result = await cms.client.getCollection("favorites", {
limit: options.limit || 12,
page: options.page || 1,
locale: (options.locale || "de") as "de" | "en",
depth: 1,
where,
})
return result as unknown as PaginatedResponse<Favorite>
} catch {
return { docs: [], totalDocs: 0, limit: 12, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
}
if (options.badge) {
params.append('where[badge][equals]', options.badge)
}
return fetchAPI<PaginatedResponse<Favorite>>(
`/api/favorites?${params}`,
{ tags: ['favorites'], defaultValue: emptyPaginatedResponse as PaginatedResponse<Favorite> }
)
}
export async function getFavorite(
slug: string,
locale = 'de'
): Promise<Favorite | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[isActive][equals]': 'true',
locale,
depth: '1',
export async function getFavorite(slug: string, locale = "de"): Promise<Favorite | null> {
const data = await cms.client.getCollection("favorites", {
locale: locale as "de" | "en",
depth: 1,
where: { "slug][equals": slug, "isActive][equals": "true" },
limit: 1,
})
const data = await fetchAPI<PaginatedResponse<Favorite>>(
`/api/favorites?${params}`,
{ tags: [`favorite-${slug}`] }
)
return data.docs[0] || null
return (data.docs[0] as unknown as Favorite) ?? null
}
// BlogWoman: Series
@ -300,58 +184,41 @@ export async function getSeries(options: {
limit?: number
locale?: string
} = {}): Promise<PaginatedResponse<Series>> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[isActive][equals]': 'true',
limit: String(options.limit || 20),
locale: options.locale || 'de',
depth: '2',
})
return fetchAPI<PaginatedResponse<Series>>(
`/api/series?${params}`,
{ tags: ['series'], defaultValue: emptyPaginatedResponse as PaginatedResponse<Series> }
)
try {
const result = await cms.client.getCollection("series", {
limit: options.limit || 20,
locale: (options.locale || "de") as "de" | "en",
depth: 2,
where: { "isActive][equals": "true" },
})
return result as unknown as PaginatedResponse<Series>
} catch {
return { docs: [], totalDocs: 0, limit: 20, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
}
}
export async function getSeriesBySlug(
slug: string,
locale = 'de'
): Promise<Series | null> {
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[isActive][equals]': 'true',
locale,
depth: '2',
export async function getSeriesBySlug(slug: string, locale = "de"): Promise<Series | null> {
const data = await cms.client.getCollection("series", {
locale: locale as "de" | "en",
depth: 2,
where: { "slug][equals": slug, "isActive][equals": "true" },
limit: 1,
})
const data = await fetchAPI<PaginatedResponse<Series>>(
`/api/series?${params}`,
{ tags: [`series-${slug}`] }
)
return data.docs[0] || null
return (data.docs[0] as unknown as Series) ?? null
}
// Newsletter Subscription
export async function subscribeNewsletter(
email: string,
firstName?: string,
source = 'website'
source = "website"
): Promise<{ success: boolean; message?: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/newsletter/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
firstName,
tenantId: Number(TENANT_ID),
source,
}),
return cms.post("/api/newsletter/subscribe", {
email,
firstName,
tenantId: Number(TENANT_ID),
source,
})
return res.json()
}
// Contact Form Submission
@ -363,20 +230,14 @@ export async function submitContactForm(data: {
message: string
formId?: number
}): Promise<{ success: boolean; message?: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/form-submissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
form: data.formId || 1,
submissionData: [
{ field: 'name', value: data.name },
{ field: 'email', value: data.email },
{ field: 'phone', value: data.phone || '' },
{ field: 'subject', value: data.subject },
{ field: 'message', value: data.message },
],
}),
return cms.post("/api/form-submissions", {
form: data.formId || 1,
submissionData: [
{ field: "name", value: data.name },
{ field: "email", value: data.email },
{ field: "phone", value: data.phone || "" },
{ field: "subject", value: data.subject },
{ field: "message", value: data.message },
],
})
return res.json()
}

15
src/lib/cms.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Payload CMS Client initialized from @c2s/payload-contracts
*
* Single shared client instance for all API calls.
* Tenant isolation is handled automatically.
*/
import { createPayloadClient } from "@c2s/payload-contracts/api-client"
export const cms = createPayloadClient({
baseUrl: process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://cms.c2sgmbh.de",
tenantId: process.env.NEXT_PUBLIC_TENANT_ID || "9",
defaultLocale: "de",
defaultDepth: 2,
defaultRevalidate: 60,
})