feat: implement CMS-driven navigation with dropdown submenus

Bypass broken contracts client navigation filter (queries non-existent
'type' field) with direct fetch to /api/navigations. Transform CMS
mainMenu structure (page/custom/submenu types) into NavItem format.
Replace JS-state dropdown (AnimatePresence) with pure CSS group-hover
to fix SSR hydration issues where both dropdowns opened simultaneously.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-16 23:22:04 +00:00
parent 3f962c6668
commit b906cbb2b1
3 changed files with 63 additions and 28 deletions

View file

@ -42,15 +42,44 @@ export default async function RootLayout({
getSocialLinks(),
])
// Transform CMS navigation items into NavItem format
// Transform CMS navigation into NavItem format
const nav = navigation as unknown as Record<string, unknown> | null
const navItems = nav?.items
? (nav.items as Array<Record<string, unknown>>).map(
(item) => ({
label: (item.label as string) || '',
href: (item.link as string) || (item.url as string) || '#',
const mainMenu = (nav?.mainMenu || nav?.items) as Array<Record<string, unknown>> | undefined
function pageSlugToHref(page: Record<string, unknown> | null): string {
if (!page?.slug) return '#'
return page.slug === 'startseite' ? '/' : `/${page.slug}`
}
function buildSubmenuItems(items: Array<Record<string, unknown>>): { label: string; href: string }[] {
return items.map((sub) => {
const subPage = sub.page as Record<string, unknown> | null
const subUrl = sub.url as string | null
const linkType = sub.linkType as string
return {
label: (sub.label as string) || '',
href: linkType === 'page' ? pageSlugToHref(subPage) : (subUrl || '#'),
}
})
}
const navItems = mainMenu?.length
? mainMenu.map((item) => {
const type = item.type as string
const page = item.page as Record<string, unknown> | null
const submenu = item.submenu as Array<Record<string, unknown>> | undefined
const children = submenu?.length ? buildSubmenuItems(submenu) : undefined
let href = '#'
if (type === 'page') href = pageSlugToHref(page)
else if (type === 'custom') href = (item.url as string) || '#'
return {
label: (item.label as string) || '',
href,
children,
}
})
)
: [
{ label: 'Home', href: '/' },
{ label: 'Der Mensch', href: '/mensch' },

View file

@ -22,7 +22,6 @@ interface NavigationProps {
export function Navigation({ items, logo, transparent = false }: NavigationProps) {
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
const pathname = usePathname()
useEffect(() => {
@ -75,10 +74,8 @@ export function Navigation({ items, logo, transparent = false }: NavigationProps
<div className="hidden lg:flex items-center gap-8">
{items.map((item) => (
<div
key={item.href}
className="relative"
onMouseEnter={() => item.children && setActiveDropdown(item.href)}
onMouseLeave={() => setActiveDropdown(null)}
key={item.label}
className="group relative"
>
<Link
href={item.href}
@ -93,16 +90,17 @@ export function Navigation({ items, logo, transparent = false }: NavigationProps
{item.label}
</Link>
{/* Dropdown */}
<AnimatePresence>
{item.children && activeDropdown === item.href && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className="absolute top-full left-0 mt-3 py-5 pb-2.5 min-w-[200px] bg-white/95 border border-light"
{/* Dropdown — hidden by default, shown on group hover via CSS */}
{item.children && (
<div
className={cn(
'absolute top-full left-0 pt-3 min-w-[200px]',
'opacity-0 invisible translate-y-2',
'group-hover:opacity-100 group-hover:visible group-hover:translate-y-0',
'transition-all duration-200'
)}
>
<div className="py-5 pb-2.5 bg-white/95 border border-light">
{item.children.map((child) => (
<Link
key={child.href}
@ -119,9 +117,9 @@ export function Navigation({ items, logo, transparent = false }: NavigationProps
{child.label}
</Link>
))}
</motion.div>
</div>
</div>
)}
</AnimatePresence>
</div>
))}
</div>
@ -149,7 +147,7 @@ export function Navigation({ items, logo, transparent = false }: NavigationProps
>
<div className="py-4">
{items.map((item) => (
<div key={item.href}>
<div key={item.label}>
<Link
href={item.href}
className="block px-4 py-3 font-heading uppercase text-[0.85em] tracking-[2px] text-gray-light hover:text-dark border-none"

View file

@ -50,9 +50,17 @@ export async function getPosts(options: {
}
// Navigation
export async function getNavigation(type: "header" | "footer" | "mobile" = "header") {
// Note: cms.navigation.getNavigation filters by a non-existent 'type' field,
// causing API errors. Query the collection directly instead.
export async function getNavigation(_type: "header" | "footer" | "mobile" = "header") {
try {
return await cms.navigation.getNavigation(type, { depth: 2 })
const baseUrl = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://cms.c2sgmbh.de"
const tenantId = process.env.NEXT_PUBLIC_TENANT_ID || "4"
const url = `${baseUrl}/api/navigations?where[tenant][equals]=${tenantId}&limit=1&depth=2&locale=de`
const res = await fetch(url, { next: { revalidate: 60, tags: ["navigations"] } })
if (!res.ok) return null
const data = await res.json() as { docs?: Record<string, unknown>[] }
return data?.docs?.[0] || null
} catch {
return null
}