All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m25s
Build & Deploy / 🏗️ Build (push) Successful in 4m56s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m3s
Build & Deploy / 🔔 Notify (push) Successful in 3s
- Add explicit close (×) button inside mobile nav overlay
- Was unreachable because header's hamburger was behind overlay z-index
- New button lives inside the overlay at full z-index visibility
- Fix check-forms.ts: authenticate via Gatekeeper login form
- Old approach: inject raw password as session cookie (didn't work)
- New approach: navigate to protected page, detect Gatekeeper gate,
fill password form and submit to get a real server-signed session cookie
- Fixes E2E form tests that failed because pages returned Gatekeeper HTML
473 lines
18 KiB
TypeScript
473 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import Image from 'next/image';
|
|
import { useTranslations } from 'next-intl';
|
|
import { usePathname } from 'next/navigation';
|
|
import { Button } from './ui';
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import { cn } from './ui';
|
|
import { useAnalytics } from './analytics/useAnalytics';
|
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
|
|
|
export default function Header() {
|
|
const t = useTranslations('Navigation');
|
|
const pathname = usePathname();
|
|
const { trackEvent } = useAnalytics();
|
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Extract locale from pathname
|
|
const currentLocale = pathname.split('/')[1] || 'en';
|
|
|
|
// Check if homepage
|
|
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
|
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
setIsScrolled(window.scrollY > 50);
|
|
};
|
|
|
|
window.addEventListener('scroll', handleScroll);
|
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
// Prevent scroll when mobile menu is open and handle focus trap
|
|
useEffect(() => {
|
|
if (isMobileMenuOpen) {
|
|
document.body.style.overflow = 'hidden';
|
|
// Focus trap logic
|
|
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
);
|
|
|
|
if (focusableElements && focusableElements.length > 0) {
|
|
const firstElement = focusableElements[0] as HTMLElement;
|
|
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
|
|
|
const handleTabKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Tab') {
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === firstElement) {
|
|
e.preventDefault();
|
|
lastElement.focus();
|
|
}
|
|
} else {
|
|
if (document.activeElement === lastElement) {
|
|
e.preventDefault();
|
|
firstElement.focus();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleEscapeKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setIsMobileMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleTabKey);
|
|
document.addEventListener('keydown', handleEscapeKey);
|
|
|
|
// Focus the first element when menu opens
|
|
setTimeout(() => firstElement.focus(), 100);
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', handleTabKey);
|
|
document.removeEventListener('keydown', handleEscapeKey);
|
|
};
|
|
}
|
|
} else {
|
|
document.body.style.overflow = 'unset';
|
|
}
|
|
}, [isMobileMenuOpen]);
|
|
|
|
// Function to get path for a different locale with segment translation
|
|
const getPathForLocale = (newLocale: string) => {
|
|
const segments = pathname.split('/');
|
|
const originLocale = segments[1] || 'en';
|
|
|
|
// Translation map for localized URL segments
|
|
const segmentMap: Record<string, Record<string, string>> = {
|
|
de: {
|
|
produkte: 'products',
|
|
kontakt: 'contact',
|
|
impressum: 'legal-notice',
|
|
datenschutz: 'privacy-policy',
|
|
agbs: 'terms',
|
|
niederspannungskabel: 'low-voltage-cables',
|
|
mittelspannungskabel: 'medium-voltage-cables',
|
|
hochspannungskabel: 'high-voltage-cables',
|
|
solarkabel: 'solar-cables',
|
|
},
|
|
en: {
|
|
products: 'produkte',
|
|
contact: 'kontakt',
|
|
'legal-notice': 'impressum',
|
|
'privacy-policy': 'datenschutz',
|
|
terms: 'agbs',
|
|
'low-voltage-cables': 'niederspannungskabel',
|
|
'medium-voltage-cables': 'mittelspannungskabel',
|
|
'high-voltage-cables': 'hochspannungskabel',
|
|
'solar-cables': 'solarkabel',
|
|
},
|
|
};
|
|
|
|
// Replace the locale segment
|
|
segments[1] = newLocale;
|
|
|
|
// Translate other segments if they exist in our map
|
|
const translatedSegments = segments.map((segment, index) => {
|
|
if (index <= 1) return segment; // Skip empty and locale segments
|
|
|
|
const mapping = segmentMap[originLocale as keyof typeof segmentMap];
|
|
return mapping && mapping[segment] ? mapping[segment] : segment;
|
|
});
|
|
|
|
return translatedSegments.join('/');
|
|
};
|
|
|
|
const menuItems = [
|
|
{ label: t('home'), href: '/' },
|
|
{ label: t('team'), href: '/team' },
|
|
{ label: t('products'), href: currentLocale === 'de' ? '/produkte' : '/products' },
|
|
{ label: t('blog'), href: '/blog' },
|
|
];
|
|
|
|
const headerClass = cn(
|
|
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu fill-mode-both',
|
|
{
|
|
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
|
isHomePage && !isScrolled && !isMobileMenuOpen,
|
|
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
|
!isHomePage || isScrolled || isMobileMenuOpen,
|
|
},
|
|
);
|
|
|
|
const textColorClass = 'text-white';
|
|
const logoSrc = '/logo-white.svg';
|
|
|
|
return (
|
|
<>
|
|
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
|
<div className="flex-shrink-0 group touch-target fill-mode-both">
|
|
<Link
|
|
href={`/${currentLocale}`}
|
|
onClick={() =>
|
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
target: 'home_logo',
|
|
location: 'header',
|
|
})
|
|
}
|
|
>
|
|
<Image
|
|
src={logoSrc}
|
|
alt={t('home')}
|
|
width={120}
|
|
height={120}
|
|
style={{ width: 'auto' }}
|
|
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
|
priority
|
|
fetchPriority="high"
|
|
loading="eager"
|
|
decoding="sync"
|
|
/>
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 md:gap-12">
|
|
<nav className="hidden lg:flex items-center space-x-10">
|
|
{menuItems.map((item, idx) => (
|
|
<div
|
|
key={item.href}
|
|
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
|
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
|
>
|
|
{(() => {
|
|
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
|
|
const isActive =
|
|
item.href === '/'
|
|
? pathname === `/${currentLocale}` || pathname === '/'
|
|
: pathname.startsWith(fullHref);
|
|
return (
|
|
<Link
|
|
href={fullHref}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
onClick={() => {
|
|
setIsMobileMenuOpen(false);
|
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
|
label: item.label,
|
|
href: item.href,
|
|
location: 'header_nav',
|
|
});
|
|
}}
|
|
className={cn(
|
|
textColorClass,
|
|
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
|
isActive && 'text-accent',
|
|
)}
|
|
>
|
|
{item.label}
|
|
<span
|
|
className={cn(
|
|
'absolute -bottom-2 left-0 h-1 bg-accent transition-all duration-500 rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]',
|
|
isActive ? 'w-full' : 'w-0 group-hover:w-full',
|
|
)}
|
|
/>
|
|
</Link>
|
|
);
|
|
})()}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
|
|
<div
|
|
className={cn(
|
|
'hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both',
|
|
textColorClass,
|
|
)}
|
|
style={{ animationDuration: '600ms', animationDelay: '300ms' }}
|
|
>
|
|
<div
|
|
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase animate-in fade-in slide-in-from-left-4 fill-mode-both"
|
|
style={{ animationDuration: '500ms', animationDelay: '600ms' }}
|
|
>
|
|
<div>
|
|
<Link
|
|
href={getPathForLocale('en')}
|
|
onClick={() =>
|
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
|
type: 'language',
|
|
from: currentLocale,
|
|
to: 'en',
|
|
location: 'header',
|
|
})
|
|
}
|
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
|
>
|
|
EN
|
|
</Link>
|
|
</div>
|
|
<div className="w-px h-4 bg-current opacity-30" />
|
|
<div>
|
|
<Link
|
|
href={getPathForLocale('de')}
|
|
onClick={() =>
|
|
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
|
type: 'language',
|
|
from: currentLocale,
|
|
to: 'de',
|
|
location: 'header',
|
|
})
|
|
}
|
|
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
|
>
|
|
DE
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
|
>
|
|
<Button
|
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
|
variant="white"
|
|
size="md"
|
|
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
|
onClick={() =>
|
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
label: t('contact'),
|
|
location: 'header_cta',
|
|
})
|
|
}
|
|
>
|
|
{t('contact')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Menu Button */}
|
|
<button
|
|
className={cn(
|
|
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
|
|
textColorClass,
|
|
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
|
)}
|
|
aria-label={t('toggleMenu')}
|
|
aria-expanded={isMobileMenuOpen}
|
|
aria-controls="mobile-menu"
|
|
onClick={() => {
|
|
const newState = !isMobileMenuOpen;
|
|
setIsMobileMenuOpen(newState);
|
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
type: 'mobile_menu',
|
|
action: newState ? 'open' : 'close',
|
|
});
|
|
}}
|
|
>
|
|
<svg
|
|
className="w-7 h-7 transition-transform duration-300"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
{isMobileMenuOpen ? (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
) : (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 6h16M4 12h16M4 18h16"
|
|
/>
|
|
)}
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Mobile Menu Overlay */}
|
|
<div
|
|
className={cn(
|
|
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
|
isMobileMenuOpen
|
|
? 'opacity-100 translate-y-0'
|
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
|
)}
|
|
id="mobile-menu"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('menu')}
|
|
ref={mobileMenuRef}
|
|
inert={isMobileMenuOpen ? undefined : true}
|
|
>
|
|
{/* Close Button inside overlay */}
|
|
<div className="flex justify-end p-6 pt-8">
|
|
<button
|
|
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
|
aria-label={t('toggleMenu')}
|
|
onClick={() => {
|
|
setIsMobileMenuOpen(false);
|
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
type: 'mobile_menu',
|
|
action: 'close',
|
|
});
|
|
}}
|
|
>
|
|
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
|
{menuItems.map((item, idx) => (
|
|
<div
|
|
key={item.href}
|
|
className={cn(
|
|
'transition-all duration-500 transform',
|
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
|
)}
|
|
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
|
>
|
|
<Link
|
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
|
aria-current={
|
|
(
|
|
item.href === '/'
|
|
? pathname === `/${currentLocale}` || pathname === '/'
|
|
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
|
)
|
|
? 'page'
|
|
: undefined
|
|
}
|
|
onClick={() => {
|
|
setIsMobileMenuOpen(false);
|
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
|
label: item.label,
|
|
href: item.href,
|
|
location: 'mobile_menu',
|
|
});
|
|
}}
|
|
className={cn(
|
|
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
|
(item.href === '/'
|
|
? pathname === `/${currentLocale}` || pathname === '/'
|
|
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
</div>
|
|
))}
|
|
|
|
<div
|
|
className={cn(
|
|
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
|
)}
|
|
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
|
>
|
|
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
|
<div>
|
|
<Link
|
|
href={getPathForLocale('en')}
|
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
|
>
|
|
EN
|
|
</Link>
|
|
</div>
|
|
<div className="w-px h-6 bg-white/30" />
|
|
<div>
|
|
<Link
|
|
href={getPathForLocale('de')}
|
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
|
>
|
|
DE
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full max-w-xs">
|
|
<Button
|
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
|
variant="accent"
|
|
size="lg"
|
|
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
|
>
|
|
{t('contact')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Branding */}
|
|
<div
|
|
className={cn(
|
|
'p-12 flex justify-center transition-all duration-700',
|
|
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
|
)}
|
|
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
|
>
|
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|