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(), 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 nav = navigation as unknown as Record<string, unknown> | null
const navItems = nav?.items const mainMenu = (nav?.mainMenu || nav?.items) as Array<Record<string, unknown>> | undefined
? (nav.items as Array<Record<string, unknown>>).map(
(item) => ({ function pageSlugToHref(page: Record<string, unknown> | null): string {
label: (item.label as string) || '', if (!page?.slug) return '#'
href: (item.link as string) || (item.url as string) || '#', 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: 'Home', href: '/' },
{ label: 'Der Mensch', href: '/mensch' }, { label: 'Der Mensch', href: '/mensch' },

View file

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