All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 27s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 QA (push) Successful in 1m46s
Build & Deploy / 🏗️ Build (push) Successful in 3m38s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m19s
Build & Deploy / 🔔 Notify (push) Successful in 5s
232 lines
9.2 KiB
TypeScript
232 lines
9.2 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { usePathname } from 'next/navigation';
|
|
import { TransitionLink } from '@/components/ui/TransitionLink';
|
|
import { Home, Layers, Star, Mail, Menu } from 'lucide-react';
|
|
import { m, AnimatePresence } from 'framer-motion';
|
|
|
|
export interface NavLink {
|
|
label: string;
|
|
url: string;
|
|
children?: NavLink[];
|
|
}
|
|
|
|
interface MobileBottomNavProps {
|
|
navLinks: NavLink[];
|
|
currentLocale: string;
|
|
}
|
|
|
|
export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProps) {
|
|
const pathname = usePathname() || '/';
|
|
const [isFlyoutOpen, setIsFlyoutOpen] = React.useState(false);
|
|
|
|
// Scroll lock when flyout is open
|
|
React.useEffect(() => {
|
|
if (isFlyoutOpen) {
|
|
document.body.style.overflow = 'hidden';
|
|
document.documentElement.style.overflow = 'hidden';
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
document.documentElement.style.overflow = '';
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
document.documentElement.style.overflow = '';
|
|
};
|
|
}, [isFlyoutOpen]);
|
|
|
|
const items = React.useMemo(() => {
|
|
const homeUrl = `/${currentLocale}`;
|
|
const competenciesUrl = navLinks[0]?.url || `/${currentLocale}/kompetenzen`;
|
|
const referencesUrl = navLinks[2]?.url || `/${currentLocale}/referenzen`;
|
|
const contactUrl = `/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`;
|
|
|
|
return [
|
|
{
|
|
id: 'home',
|
|
label: currentLocale === 'de' ? 'Start' : 'Home',
|
|
url: homeUrl,
|
|
icon: <Home className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />,
|
|
exactMatch: true
|
|
},
|
|
{
|
|
id: 'kompetenzen',
|
|
label: navLinks[0]?.label || (currentLocale === 'de' ? 'Kompetenzen' : 'Competencies'),
|
|
url: competenciesUrl,
|
|
icon: <Layers className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
|
},
|
|
{
|
|
id: 'referenzen',
|
|
label: navLinks[2]?.label || (currentLocale === 'de' ? 'Referenzen' : 'References'),
|
|
url: referencesUrl,
|
|
icon: <Star className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
|
},
|
|
{
|
|
id: 'kontakt',
|
|
label: currentLocale === 'de' ? 'Kontakt' : 'Contact',
|
|
url: contactUrl,
|
|
icon: <Mail className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
|
},
|
|
{
|
|
id: 'menu',
|
|
label: currentLocale === 'de' ? 'Mehr' : 'Menu',
|
|
url: '#',
|
|
icon: <Menu className="w-[24px] h-[24px] md:w-6 md:h-6" strokeWidth={2.5} />,
|
|
isFlyoutTrigger: true
|
|
}
|
|
];
|
|
}, [currentLocale, navLinks]);
|
|
|
|
const flyoutLinks = React.useMemo(() => {
|
|
const aboutNode = navLinks[1];
|
|
const karriereNode = navLinks[3];
|
|
const messenNode = navLinks[4];
|
|
|
|
let links: {label: string, url: string}[] = [];
|
|
if (aboutNode?.children) {
|
|
links.push(...aboutNode.children.map(c => ({ label: c.label, url: c.url })));
|
|
}
|
|
if (karriereNode) links.push({ label: karriereNode.label, url: karriereNode.url });
|
|
if (messenNode) links.push({ label: messenNode.label, url: messenNode.url });
|
|
return links;
|
|
}, [navLinks]);
|
|
|
|
return (
|
|
<>
|
|
<div className="md:hidden fixed bottom-0 left-0 right-0 z-[9999] px-2 pb-[calc(env(safe-area-inset-bottom)_+_12px)] pt-4 pointer-events-none flex justify-center">
|
|
<nav className="flex justify-between items-center bg-white/95 backdrop-blur-2xl border border-white/60 shadow-[0_8px_32px_rgba(0,0,0,0.12)] rounded-3xl p-1.5 pointer-events-auto w-full max-w-[480px]">
|
|
{items.map((item) => {
|
|
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
|
? `/${currentLocale}${item.url}`
|
|
: item.url;
|
|
|
|
let isActive = false;
|
|
if (item.isFlyoutTrigger) {
|
|
isActive = isFlyoutOpen;
|
|
} else {
|
|
isActive = item.exactMatch
|
|
? pathname === mappedUrl
|
|
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
|
|
}
|
|
|
|
const innerContent = (
|
|
<>
|
|
{isActive && (
|
|
<m.div
|
|
layoutId="mobile-nav-active-bg"
|
|
className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10"
|
|
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
|
/>
|
|
)}
|
|
|
|
<div className="relative flex flex-col items-center justify-center z-10 w-full h-full">
|
|
<m.div
|
|
whileTap={{ scale: 0.8 }}
|
|
animate={{ y: isActive ? -2 : 0 }}
|
|
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
|
>
|
|
{item.icon}
|
|
</m.div>
|
|
<m.span
|
|
animate={{ y: isActive ? 0 : 2 }}
|
|
className="text-[10px] font-bold mt-1 tracking-wide"
|
|
>
|
|
{item.label}
|
|
</m.span>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
if (item.isFlyoutTrigger) {
|
|
return (
|
|
<button
|
|
key={item.label}
|
|
onClick={() => setIsFlyoutOpen(!isFlyoutOpen)}
|
|
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none w-full focus:outline-none ${
|
|
isActive ? 'text-primary-dark' : 'text-neutral-dark hover:text-black'
|
|
}`}
|
|
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
|
>
|
|
{innerContent}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TransitionLink
|
|
key={item.label}
|
|
href={mappedUrl}
|
|
onClick={() => setIsFlyoutOpen(false)}
|
|
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none ${
|
|
isActive ? 'text-primary-dark' : 'text-neutral-dark hover:text-black'
|
|
}`}
|
|
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
|
>
|
|
{innerContent}
|
|
</TransitionLink>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{isFlyoutOpen && (
|
|
<>
|
|
<m.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[9997] md:hidden"
|
|
onClick={() => setIsFlyoutOpen(false)}
|
|
/>
|
|
<m.div
|
|
initial={{ y: '100%', opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: '100%', opacity: 0 }}
|
|
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
|
|
drag="y"
|
|
dragConstraints={{ top: 0, bottom: 0 }}
|
|
dragElastic={0.2}
|
|
onDragEnd={(e, info) => {
|
|
if (info.offset.y > 50 || info.velocity.y > 100) {
|
|
setIsFlyoutOpen(false);
|
|
}
|
|
}}
|
|
className="fixed bottom-0 left-0 right-0 z-[9998] bg-white rounded-t-[32px] pt-4 pb-[calc(env(safe-area-inset-bottom)_+_7rem)] px-6 md:hidden shadow-[0_-20px_40px_rgba(0,0,0,0.1)] border-t border-neutral-100"
|
|
>
|
|
<div className="w-16 h-1.5 bg-neutral-200 rounded-full mx-auto mb-6 cursor-grab active:cursor-grabbing" />
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-4 px-2">{currentLocale === 'de' ? 'Über uns & Mehr' : 'About us & More'}</h3>
|
|
<div className="flex flex-col gap-1">
|
|
{flyoutLinks.map((link, i) => {
|
|
const childUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
|
|
? `/${currentLocale}${link.url}`
|
|
: link.url;
|
|
const isChildActive = pathname === childUrl;
|
|
|
|
return (
|
|
<TransitionLink
|
|
key={i}
|
|
href={childUrl}
|
|
onClick={() => setIsFlyoutOpen(false)}
|
|
className={`px-4 py-4 rounded-2xl text-[15px] font-semibold transition-all flex items-center justify-between ${
|
|
isChildActive ? 'bg-primary/10 text-primary' : 'text-neutral-700 hover:bg-neutral-50 active:bg-neutral-100'
|
|
}`}
|
|
>
|
|
{link.label}
|
|
<svg className={`w-4 h-4 ${isChildActive ? 'text-primary' : 'text-neutral-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</TransitionLink>
|
|
);
|
|
})}
|
|
</div>
|
|
</m.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|