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"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ transpilePackages: ["@c2s/payload-contracts"],
}; };
export default nextConfig; export default nextConfig;

View file

@ -13,7 +13,8 @@
"next": "16.0.10", "next": "16.0.10",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "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": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View file

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: 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: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@ -122,6 +125,15 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'} 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': '@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
@ -2021,6 +2033,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@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': '@emnapi/core@1.7.1':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.1.0 '@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 { import type {
Page, Page,
Post, Post,
Navigation, Navigation,
SiteSettings,
Favorite, Favorite,
Series, Series,
Testimonial, Testimonial,
FAQ, FAQ,
SeoSettings, SeoSettings,
PaginatedResponse, PaginatedResponse,
FavoriteCategory, SiteSettings,
FavoriteBadge, } from "./types"
} from './types'
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || 'https://cms.c2sgmbh.de' const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://cms.c2sgmbh.de"
const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID || '9' 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
}
}
// Pages // Pages
export async function getPage( export async function getPage(slug: string, locale = "de"): Promise<Page | null> {
slug: string, const result = await cms.pages.getPage(slug, {
locale = 'de' locale: locale as "de" | "en",
): Promise<Page | null> { depth: 2,
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[status][equals]': 'published',
locale,
depth: '2',
}) })
return result as unknown as Page | null
const data = await fetchAPI<PaginatedResponse<Page>>(
`/api/pages?${params}`,
{ tags: [`page-${slug}`] }
)
return data.docs[0] || null
} }
export async function getPages(options: { export async function getPages(options: {
@ -82,41 +38,26 @@ export async function getPages(options: {
page?: number page?: number
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<Page>> { } = {}): Promise<PaginatedResponse<Page>> {
const params = new URLSearchParams({ const result = await cms.pages.getPages({
'where[tenant][equals]': TENANT_ID, limit: options.limit || 100,
'where[status][equals]': 'published', page: options.page || 1,
limit: String(options.limit || 100), locale: (options.locale || "de") as "de" | "en",
page: String(options.page || 1), depth: 1,
locale: options.locale || 'de',
depth: '1',
}) })
return result as unknown as PaginatedResponse<Page>
return fetchAPI<PaginatedResponse<Page>>(`/api/pages?${params}`)
} }
// Posts // Posts
export async function getPost( export async function getPost(slug: string, locale = "de"): Promise<Post | null> {
slug: string, const result = await cms.posts.getPost(slug, {
locale = 'de' locale: locale as "de" | "en",
): Promise<Post | null> { depth: 2,
const params = new URLSearchParams({
'where[tenant][equals]': TENANT_ID,
'where[slug][equals]': slug,
'where[status][equals]': 'published',
locale,
depth: '2',
}) })
return result as unknown as Post | null
const data = await fetchAPI<PaginatedResponse<Post>>(
`/api/posts?${params}`,
{ tags: [`post-${slug}`] }
)
return data.docs[0] || null
} }
export async function getPosts(options: { export async function getPosts(options: {
type?: 'blog' | 'news' | 'press' | 'announcement' type?: string
category?: string category?: string
series?: string series?: string
limit?: number limit?: number
@ -124,83 +65,53 @@ export async function getPosts(options: {
locale?: string locale?: string
featured?: boolean featured?: boolean
} = {}): Promise<PaginatedResponse<Post>> { } = {}): Promise<PaginatedResponse<Post>> {
const params = new URLSearchParams({ try {
'where[tenant][equals]': TENANT_ID, const where: Record<string, unknown> = {}
'where[status][equals]': 'published', if (options.featured) where["isFeatured][equals"] = "true"
sort: '-publishedAt',
limit: String(options.limit || 10), const result = await cms.posts.getPosts({
page: String(options.page || 1), type: options.type,
locale: options.locale || 'de', category: options.category,
depth: '1', 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>
if (options.type) { } catch {
params.append('where[type][equals]', options.type) 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 // Navigation
export async function getNavigation( export async function getNavigation(type: "header" | "footer" | "mobile"): Promise<Navigation | null> {
type: 'header' | 'footer' | 'mobile' try {
): Promise<Navigation | null> { const result = await cms.navigation.getNavigation(type, { depth: 2 })
const params = new URLSearchParams({ return result as unknown as Navigation | null
'where[tenant][equals]': TENANT_ID, } catch {
'where[type][equals]': type, return null
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
} }
// Site Settings // Site Settings
export async function getSiteSettings(): Promise<SiteSettings | null> { export async function getSiteSettings(): Promise<SiteSettings | null> {
const params = new URLSearchParams({ try {
'where[tenant][equals]': TENANT_ID, const result = await cms.settings.getSiteSettings({ depth: 2 })
depth: '2', return result as unknown as SiteSettings | null
}) } catch {
return null
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
} }
// SEO Settings (Global) // SEO Settings (Global)
export async function getSeoSettings(): Promise<SeoSettings | null> { export async function getSeoSettings(): Promise<SeoSettings | null> {
return fetchAPI<SeoSettings>('/api/globals/seo-settings', { try {
revalidate: 3600, const result = await cms.settings.getSeoSettings()
tags: ['seo-settings'], return result as unknown as SeoSettings | null
defaultValue: null as unknown as SeoSettings, } catch {
}) return null
}
} }
// Testimonials // Testimonials
@ -208,17 +119,12 @@ export async function getTestimonials(options: {
limit?: number limit?: number
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<Testimonial>> { } = {}): Promise<PaginatedResponse<Testimonial>> {
const params = new URLSearchParams({ const result = await cms.client.getCollection("testimonials", {
'where[tenant][equals]': TENANT_ID, limit: options.limit || 10,
limit: String(options.limit || 10), locale: (options.locale || "de") as "de" | "en",
locale: options.locale || 'de', depth: 1,
depth: '1',
}) })
return result as unknown as PaginatedResponse<Testimonial>
return fetchAPI<PaginatedResponse<Testimonial>>(
`/api/testimonials?${params}`,
{ tags: ['testimonials'] }
)
} }
// FAQs // FAQs
@ -227,72 +133,50 @@ export async function getFAQs(options: {
limit?: number limit?: number
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<FAQ>> { } = {}): Promise<PaginatedResponse<FAQ>> {
const params = new URLSearchParams({ const result = await cms.client.getCollection("faqs", {
'where[tenant][equals]': TENANT_ID, limit: options.limit || 50,
sort: 'order', locale: (options.locale || "de") as "de" | "en",
limit: String(options.limit || 50), sort: "order",
locale: options.locale || 'de', depth: 1,
depth: '1', where: options.category ? { "category][equals": options.category } : undefined,
}) })
return result as unknown as PaginatedResponse<FAQ>
if (options.category) {
params.append('where[category][equals]', options.category)
}
return fetchAPI<PaginatedResponse<FAQ>>(
`/api/faqs?${params}`,
{ tags: ['faqs'] }
)
} }
// BlogWoman: Favorites // BlogWoman: Favorites
export async function getFavorites(options: { export async function getFavorites(options: {
category?: FavoriteCategory category?: string
badge?: FavoriteBadge badge?: string
limit?: number limit?: number
page?: number page?: number
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<Favorite>> { } = {}): Promise<PaginatedResponse<Favorite>> {
const params = new URLSearchParams({ try {
'where[tenant][equals]': TENANT_ID, const where: Record<string, unknown> = { "isActive][equals": "true" }
'where[isActive][equals]': 'true', if (options.category) where["category][equals"] = options.category
limit: String(options.limit || 12), if (options.badge) where["badge][equals"] = options.badge
page: String(options.page || 1),
locale: options.locale || 'de', const result = await cms.client.getCollection("favorites", {
depth: '1', 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>
if (options.category) { } catch {
params.append('where[category][equals]', options.category) 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( export async function getFavorite(slug: string, locale = "de"): Promise<Favorite | null> {
slug: string, const data = await cms.client.getCollection("favorites", {
locale = 'de' locale: locale as "de" | "en",
): Promise<Favorite | null> { depth: 1,
const params = new URLSearchParams({ where: { "slug][equals": slug, "isActive][equals": "true" },
'where[tenant][equals]': TENANT_ID, limit: 1,
'where[slug][equals]': slug,
'where[isActive][equals]': 'true',
locale,
depth: '1',
}) })
return (data.docs[0] as unknown as Favorite) ?? null
const data = await fetchAPI<PaginatedResponse<Favorite>>(
`/api/favorites?${params}`,
{ tags: [`favorite-${slug}`] }
)
return data.docs[0] || null
} }
// BlogWoman: Series // BlogWoman: Series
@ -300,58 +184,41 @@ export async function getSeries(options: {
limit?: number limit?: number
locale?: string locale?: string
} = {}): Promise<PaginatedResponse<Series>> { } = {}): Promise<PaginatedResponse<Series>> {
const params = new URLSearchParams({ try {
'where[tenant][equals]': TENANT_ID, const result = await cms.client.getCollection("series", {
'where[isActive][equals]': 'true', limit: options.limit || 20,
limit: String(options.limit || 20), locale: (options.locale || "de") as "de" | "en",
locale: options.locale || 'de', depth: 2,
depth: '2', where: { "isActive][equals": "true" },
}) })
return result as unknown as PaginatedResponse<Series>
return fetchAPI<PaginatedResponse<Series>>( } catch {
`/api/series?${params}`, return { docs: [], totalDocs: 0, limit: 20, totalPages: 0, page: 1, pagingCounter: 1, hasPrevPage: false, hasNextPage: false, prevPage: null, nextPage: null }
{ tags: ['series'], defaultValue: emptyPaginatedResponse as PaginatedResponse<Series> } }
)
} }
export async function getSeriesBySlug( export async function getSeriesBySlug(slug: string, locale = "de"): Promise<Series | null> {
slug: string, const data = await cms.client.getCollection("series", {
locale = 'de' locale: locale as "de" | "en",
): Promise<Series | null> { depth: 2,
const params = new URLSearchParams({ where: { "slug][equals": slug, "isActive][equals": "true" },
'where[tenant][equals]': TENANT_ID, limit: 1,
'where[slug][equals]': slug,
'where[isActive][equals]': 'true',
locale,
depth: '2',
}) })
return (data.docs[0] as unknown as Series) ?? null
const data = await fetchAPI<PaginatedResponse<Series>>(
`/api/series?${params}`,
{ tags: [`series-${slug}`] }
)
return data.docs[0] || null
} }
// Newsletter Subscription // Newsletter Subscription
export async function subscribeNewsletter( export async function subscribeNewsletter(
email: string, email: string,
firstName?: string, firstName?: string,
source = 'website' source = "website"
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/newsletter/subscribe`, { return cms.post("/api/newsletter/subscribe", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email, email,
firstName, firstName,
tenantId: Number(TENANT_ID), tenantId: Number(TENANT_ID),
source, source,
}),
}) })
return res.json()
} }
// Contact Form Submission // Contact Form Submission
@ -363,20 +230,14 @@ export async function submitContactForm(data: {
message: string message: string
formId?: number formId?: number
}): Promise<{ success: boolean; message?: string }> { }): Promise<{ success: boolean; message?: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/form-submissions`, { return cms.post("/api/form-submissions", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
form: data.formId || 1, form: data.formId || 1,
submissionData: [ submissionData: [
{ field: 'name', value: data.name }, { field: "name", value: data.name },
{ field: 'email', value: data.email }, { field: "email", value: data.email },
{ field: 'phone', value: data.phone || '' }, { field: "phone", value: data.phone || "" },
{ field: 'subject', value: data.subject }, { field: "subject", value: data.subject },
{ field: 'message', value: data.message }, { 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,
})