fix: navigation migration to payload-contracts

- Fix getNavigation() to use contracts API (no broken type filter)
- Single nav fetch in layout, pass mainMenu/footerMenu to components
- Header, Navigation, MobileMenu use CMS mainMenu schema
- Footer uses CMS footerMenu schema with linkType field
- Add pnpm-workspace.yaml for onlyBuiltDependencies allowlist
- Update payload-contracts to latest (navigation fix a0eea96)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-20 13:43:05 +00:00
parent a8e8d2861b
commit 7235d8b910
9 changed files with 153 additions and 208 deletions

View file

@ -10,7 +10,7 @@ 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)
version: git+https://git@github.com:complexcaresolutions/payload-contracts.git#a0eea9649d35ec2a4554632554d53799a36b7f4b(react@19.2.1)
clsx:
specifier: ^2.1.1
version: 2.1.1
@ -125,8 +125,8 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
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}
'@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#a0eea9649d35ec2a4554632554d53799a36b7f4b':
resolution: {commit: a0eea9649d35ec2a4554632554d53799a36b7f4b, repo: git@github.com:complexcaresolutions/payload-contracts.git, type: git}
version: 1.0.0
peerDependencies:
react: ^19.0.0
@ -2048,7 +2048,7 @@ 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)':
'@c2s/payload-contracts@git+https://git@github.com:complexcaresolutions/payload-contracts.git#a0eea9649d35ec2a4554632554d53799a36b7f4b(react@19.2.1)':
optionalDependencies:
react: 19.2.1

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- "@c2s/payload-contracts"

View file

@ -71,10 +71,9 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode
}>) {
// Fetch navigation and settings in parallel
const [headerNav, footerNav, settings] = await Promise.all([
getNavigation('header'),
getNavigation('footer'),
// Fetch navigation (one doc per tenant) and settings in parallel
const [navigation, settings] = await Promise.all([
getNavigation(),
getSiteSettings(),
])
@ -89,9 +88,9 @@ export default async function RootLayout({
Zum Hauptinhalt springen
</a>
<div className="flex min-h-screen flex-col">
<Header navigation={headerNav} settings={settings} />
<Header mainMenu={navigation?.mainMenu ?? null} settings={settings} />
<main id="main-content" className="flex-1">{children}</main>
<Footer navigation={footerNav} settings={settings} />
<Footer footerMenu={navigation?.footerMenu ?? null} settings={settings} />
</div>
<UmamiScript />
</body>

View file

@ -59,7 +59,6 @@ export function BlockRenderer({ blocks }: BlockRendererProps) {
return null
}
// Extract the block data, excluding blockType for the component props
const { blockType, ...blockProps } = block
return (

View file

@ -1,29 +1,21 @@
import Link from 'next/link'
import Image from 'next/image'
import type { Navigation, SiteSettings, Address } from '@/lib/types'
import type { Navigation as NavigationType } from '@c2s/payload-contracts/types'
import type { SiteSettings } from '@/lib/types'
// Helper to format address
function formatAddress(address: Address | string | undefined): string | null {
if (!address) return null
if (typeof address === 'string') return address
const parts: string[] = []
if (address.street) parts.push(address.street)
if (address.additionalLine) parts.push(address.additionalLine)
if (address.zip || address.city) {
parts.push([address.zip, address.city].filter(Boolean).join(' '))
}
if (address.country) parts.push(address.country)
return parts.length > 0 ? parts.join('\n') : null
}
type FooterMenuItem = NonNullable<NavigationType['footerMenu']>[number]
interface FooterProps {
navigation: Navigation | null
footerMenu: FooterMenuItem[] | null
settings: SiteSettings | null
}
export function Footer({ navigation, settings }: FooterProps) {
function getPageSlug(page: FooterMenuItem['page']): string | undefined {
if (typeof page === 'object' && page !== null) return (page as { slug: string }).slug
return undefined
}
export function Footer({ footerMenu, settings }: FooterProps) {
const currentYear = new Date().getFullYear()
return (
@ -56,84 +48,38 @@ export function Footer({ navigation, settings }: FooterProps) {
{settings?.socialLinks && (
<div className="flex gap-4 mt-6">
{settings.socialLinks.instagram && (
<a
href={settings.socialLinks.instagram}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="Instagram"
>
<SocialIcon platform="instagram" />
</a>
<SocialAnchor href={settings.socialLinks.instagram} label="Instagram" platform="instagram" />
)}
{settings.socialLinks.youtube && (
<a
href={settings.socialLinks.youtube}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="YouTube"
>
<SocialIcon platform="youtube" />
</a>
<SocialAnchor href={settings.socialLinks.youtube} label="YouTube" platform="youtube" />
)}
{settings.socialLinks.pinterest && (
<a
href={settings.socialLinks.pinterest}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="Pinterest"
>
<SocialIcon platform="pinterest" />
</a>
<SocialAnchor href={settings.socialLinks.pinterest} label="Pinterest" platform="pinterest" />
)}
{settings.socialLinks.tiktok && (
<a
href={settings.socialLinks.tiktok}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="TikTok"
>
<SocialIcon platform="tiktok" />
</a>
<SocialAnchor href={settings.socialLinks.tiktok} label="TikTok" platform="tiktok" />
)}
{settings.socialLinks.facebook && (
<a
href={settings.socialLinks.facebook}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label="Facebook"
>
<SocialIcon platform="facebook" />
</a>
<SocialAnchor href={settings.socialLinks.facebook} label="Facebook" platform="facebook" />
)}
</div>
)}
</div>
{/* Navigation Columns */}
{navigation?.items && navigation.items.length > 0 && (
<>
{groupNavigationItems(navigation.items).map((group, index) => (
<div key={index}>
{group.title && (
<h3 className="text-xs font-bold tracking-[0.1em] uppercase text-brass mb-4">
{group.title}
</h3>
)}
<ul className="space-y-2">
{group.items.map((item) => (
<li key={item.id}>
<FooterLink item={item} />
</li>
))}
</ul>
</div>
))}
</>
{/* Footer Navigation */}
{footerMenu && footerMenu.length > 0 && (
<div className="lg:col-span-2">
<h3 className="text-xs font-bold tracking-[0.1em] uppercase text-brass mb-4">
Links
</h3>
<ul className="grid grid-cols-2 gap-2">
{footerMenu.map((item) => (
<li key={item.id}>
<FooterLink item={item} />
</li>
))}
</ul>
</div>
)}
{/* Contact Column */}
@ -163,11 +109,6 @@ export function Footer({ navigation, settings }: FooterProps) {
</a>
</p>
)}
{formatAddress(settings.address) && (
<p className="text-warm-gray whitespace-pre-line">
{formatAddress(settings.address)}
</p>
)}
</address>
</div>
)}
@ -185,24 +126,20 @@ export function Footer({ navigation, settings }: FooterProps) {
)
}
function FooterLink({ item }: { item: Navigation['items'][0] }) {
function FooterLink({ item }: { item: FooterMenuItem }) {
const className = 'text-sm text-soft-white hover:text-sand transition-colors'
const slug = getPageSlug(item.page)
const href = item.linkType === 'page' && slug ? `/${slug}` : item.url || '/'
const isExternal = item.linkType === 'custom' && item.url?.startsWith('http')
if (item.type === 'external' && item.url) {
if (isExternal) {
return (
<a
href={item.url}
target={item.openInNewTab ? '_blank' : undefined}
rel={item.openInNewTab ? 'noopener noreferrer' : undefined}
className={className}
>
<a href={item.url!} target="_blank" rel="noopener noreferrer" className={className}>
{item.label}
</a>
)
}
const href = item.page?.slug ? `/${item.page.slug}` : item.url || '/'
return (
<Link href={href} className={className}>
{item.label}
@ -210,32 +147,20 @@ function FooterLink({ item }: { item: Navigation['items'][0] }) {
)
}
// Helper to group navigation items for footer columns
function groupNavigationItems(items: Navigation['items']) {
// Simple grouping - you can customize based on your needs
const groups: { title?: string; items: Navigation['items'] }[] = []
items.forEach((item) => {
if (item.type === 'submenu' && item.children?.length) {
groups.push({
title: item.label,
items: item.children,
})
}
})
// Add remaining items as a group
const topLevelItems = items.filter(
(item) => item.type !== 'submenu' || !item.children?.length
function SocialAnchor({ href, label, platform }: { href: string; label: string; platform: string }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-warm-gray hover:text-soft-white transition-colors"
aria-label={label}
>
<SocialIcon platform={platform} />
</a>
)
if (topLevelItems.length > 0) {
groups.unshift({ items: topLevelItems })
}
return groups
}
// Social Media Icons
function SocialIcon({ platform }: { platform: string }) {
const iconClass = 'w-5 h-5'
@ -258,12 +183,6 @@ function SocialIcon({ platform }: { platform: string }) {
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
)
case 'linkedin':
return (
<svg className={iconClass} fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
)
case 'pinterest':
return (
<svg className={iconClass} fill="currentColor" viewBox="0 0 24 24">
@ -279,12 +198,7 @@ function SocialIcon({ platform }: { platform: string }) {
default:
return (
<svg className={iconClass} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
)
}

View file

@ -5,14 +5,17 @@ import Link from 'next/link'
import Image from 'next/image'
import { Navigation } from './Navigation'
import { MobileMenu } from './MobileMenu'
import type { Navigation as NavigationType, SiteSettings } from '@/lib/types'
import type { Navigation as NavigationType } from '@c2s/payload-contracts/types'
import type { SiteSettings } from '@/lib/types'
type MainMenuItem = NonNullable<NavigationType['mainMenu']>[number]
interface HeaderProps {
navigation: NavigationType | null
mainMenu: MainMenuItem[] | null
settings: SiteSettings | null
}
export function Header({ navigation, settings }: HeaderProps) {
export function Header({ mainMenu, settings }: HeaderProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
return (
@ -39,7 +42,7 @@ export function Header({ navigation, settings }: HeaderProps) {
{/* Desktop Navigation */}
<div className="hidden md:block">
<Navigation items={navigation?.items || []} />
<Navigation items={mainMenu || []} />
</div>
{/* Mobile Menu Toggle */}
@ -69,7 +72,7 @@ export function Header({ navigation, settings }: HeaderProps) {
{/* Mobile Menu */}
<MobileMenu
items={navigation?.items || []}
items={mainMenu || []}
isOpen={isMobileMenuOpen}
onClose={() => setIsMobileMenuOpen(false)}
/>

View file

@ -3,10 +3,12 @@
import { useEffect, useRef, useCallback } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import type { NavigationItem } from '@/lib/types'
import type { Navigation as NavigationType, Page } from '@c2s/payload-contracts/types'
type MainMenuItem = NonNullable<NavigationType['mainMenu']>[number]
interface MobileMenuProps {
items: NavigationItem[]
items: MainMenuItem[]
isOpen: boolean
onClose: () => void
}
@ -33,7 +35,6 @@ export function MobileMenu({ items, isOpen, onClose }: MobileMenuProps) {
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement
// Small delay to allow animation to start
setTimeout(() => {
closeButtonRef.current?.focus()
}, 100)
@ -142,46 +143,39 @@ export function MobileMenu({ items, isOpen, onClose }: MobileMenuProps) {
)
}
interface MobileNavItemProps {
item: NavigationItem
onClose: () => void
depth?: number
}
function MobileNavItem({ item, onClose, depth = 0 }: MobileNavItemProps) {
function MobileNavItem({ item, onClose, depth = 0 }: { item: MainMenuItem; onClose: () => void; depth?: number }) {
const linkClasses = cn(
'block py-3 text-base font-medium text-espresso',
'hover:text-brass transition-colors',
depth > 0 && 'pl-4 text-sm'
)
// With children
if (item.type === 'submenu' && item.children?.length) {
// With submenu
if (item.type === 'submenu' && item.submenu?.length) {
return (
<li>
<span className="block py-3 text-base font-semibold text-espresso">
{item.label}
</span>
<ul className="border-l-2 border-warm-gray ml-2">
{item.children.map((child) => (
<MobileNavItem
key={child.id}
item={child}
onClose={onClose}
depth={depth + 1}
/>
{item.submenu.map((child) => (
<MobileSubItem key={child.id} item={child} onClose={onClose} />
))}
</ul>
</li>
)
}
// External link
if (item.type === 'external' && item.url) {
// Link
const slug = typeof item.page === 'object' && item.page !== null ? (item.page as Page).slug : undefined
const href = item.type === 'page' && slug ? `/${slug}` : item.url || '/'
const isExternal = item.type === 'custom' && item.url?.startsWith('http')
if (isExternal) {
return (
<li>
<a
href={item.url}
href={item.url!}
target={item.openInNewTab ? '_blank' : undefined}
rel={item.openInNewTab ? 'noopener noreferrer' : undefined}
className={linkClasses}
@ -193,8 +187,21 @@ function MobileNavItem({ item, onClose, depth = 0 }: MobileNavItemProps) {
)
}
// Internal link
const href = item.page?.slug ? `/${item.page.slug}` : item.url || '/'
return (
<li>
<Link href={href} className={linkClasses} onClick={onClose}>
{item.label}
</Link>
</li>
)
}
type SubMenuItem = NonNullable<MainMenuItem['submenu']>[number]
function MobileSubItem({ item, onClose }: { item: SubMenuItem; onClose: () => void }) {
const linkClasses = 'block py-3 pl-4 text-sm font-medium text-espresso hover:text-brass transition-colors'
const slug = typeof item.page === 'object' && item.page !== null ? (item.page as Page).slug : undefined
const href = item.linkType === 'page' && slug ? `/${slug}` : item.url || '/'
return (
<li>

View file

@ -3,13 +3,20 @@
import { useState } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import type { NavigationItem } from '@/lib/types'
import type { Navigation as NavigationType, Page } from '@c2s/payload-contracts/types'
type MainMenuItem = NonNullable<NavigationType['mainMenu']>[number]
interface NavigationProps {
items: NavigationItem[]
items: MainMenuItem[]
className?: string
}
function getPageSlug(page: MainMenuItem['page']): string | undefined {
if (typeof page === 'object' && page !== null) return (page as Page).slug
return undefined
}
export function Navigation({ items, className }: NavigationProps) {
return (
<nav className={cn('flex items-center gap-8', className)}>
@ -20,11 +27,7 @@ export function Navigation({ items, className }: NavigationProps) {
)
}
interface NavItemProps {
item: NavigationItem
}
function NavItem({ item }: NavItemProps) {
function NavItem({ item }: { item: MainMenuItem }) {
const [isOpen, setIsOpen] = useState(false)
const linkClasses = cn(
@ -34,7 +37,7 @@ function NavItem({ item }: NavItemProps) {
)
// Submenu
if (item.type === 'submenu' && item.children?.length) {
if (item.type === 'submenu' && item.submenu?.length) {
return (
<div
className="relative"
@ -76,8 +79,8 @@ function NavItem({ item }: NavItemProps) {
)}
>
<div className="bg-soft-white border border-warm-gray rounded-lg shadow-lg py-2 min-w-[200px]">
{item.children.map((child) => (
<NavLink
{item.submenu.map((child) => (
<SubNavLink
key={child.id}
item={child}
className="block px-4 py-2 text-sm text-espresso hover:bg-ivory hover:text-brass transition-colors"
@ -90,30 +93,46 @@ function NavItem({ item }: NavItemProps) {
}
// Regular link
return <NavLink item={item} className={linkClasses} />
const slug = getPageSlug(item.page)
const href = item.type === 'page' && slug ? `/${slug}` : item.url || '/'
if (item.type === 'custom' && item.url) {
const isExternal = item.url.startsWith('http')
if (isExternal) {
return (
<a
href={item.url}
target={item.openInNewTab ? '_blank' : undefined}
rel={item.openInNewTab ? 'noopener noreferrer' : undefined}
className={linkClasses}
>
{item.label}
</a>
)
}
}
return (
<Link href={href} className={linkClasses}>
{item.label}
</Link>
)
}
interface NavLinkProps {
item: NavigationItem
className?: string
}
type SubMenuItem = NonNullable<MainMenuItem['submenu']>[number]
function NavLink({ item, className }: NavLinkProps) {
if (item.type === 'external' && item.url) {
function SubNavLink({ item, className }: { item: SubMenuItem; className?: string }) {
const slug = typeof item.page === 'object' && item.page !== null ? (item.page as Page).slug : undefined
const href = item.linkType === 'page' && slug ? `/${slug}` : item.url || '/'
if (item.linkType === 'custom' && item.url?.startsWith('http')) {
return (
<a
href={item.url}
target={item.openInNewTab ? '_blank' : undefined}
rel={item.openInNewTab ? 'noopener noreferrer' : undefined}
className={className}
>
<a href={item.url} className={className} target="_blank" rel="noopener noreferrer">
{item.label}
</a>
)
}
const href = item.page?.slug ? `/${item.page.slug}` : item.url || '/'
return (
<Link href={href} className={className}>
{item.label}

View file

@ -1,17 +1,18 @@
/**
* 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.
* Uses the shared API client for transport (tenant isolation, fetch caching).
* 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.
* Navigation uses contracts types directly (schema matches).
* Other types use 'as unknown as' bridge since local types differ
* from CMS types (meta vs seo, id string vs number, etc.).
*/
import { cms } from "./cms"
import type { Navigation } from "@c2s/payload-contracts/types"
import type {
Page,
Post,
Navigation,
Favorite,
Series,
Testimonial,
@ -21,7 +22,8 @@ import type {
SiteSettings,
} from "./types"
// const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://cms.c2sgmbh.de"
export type { Navigation }
const TENANT_ID = process.env.NEXT_PUBLIC_TENANT_ID || "9"
// Pages
@ -84,11 +86,11 @@ export async function getPosts(options: {
}
}
// Navigation
export async function getNavigation(type: "header" | "footer" | "mobile"): Promise<Navigation | null> {
// Navigation — single document per tenant with mainMenu + footerMenu
// Uses contracts Navigation type directly (no bridge needed)
export async function getNavigation(): Promise<Navigation | null> {
try {
const result = await cms.navigation.getNavigation(type, { depth: 2 })
return result as unknown as Navigation | null
return await cms.navigation.getNavigation({ depth: 2 })
} catch {
return null
}