Compare commits

...

8 Commits

Author SHA1 Message Date
2cb0b50193 feat(ui): add AnimatedGlossyBorder and refine industrial interactions
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 39s
Build & Deploy / 🧪 QA (push) Successful in 1m44s
Build & Deploy / 🏗️ Build (push) Successful in 3m23s
Build & Deploy / 🚀 Deploy (push) Successful in 47s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m2s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-05-13 10:23:47 +02:00
7d6a52e1c6 feat: unified container widths and added magnetic mouse pull to corporate background arcs
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 34s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 2m59s
Build & Deploy / 🚀 Deploy (push) Successful in 43s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m0s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-12 19:08:23 +02:00
55d633a33c feat(mobile): optimize mobile layout, navigation, and spacing
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 43s
Build & Deploy / 🧪 QA (push) Successful in 2m49s
Build & Deploy / 🏗️ Build (push) Successful in 4m54s
Build & Deploy / 🚀 Deploy (push) Successful in 1m19s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m1s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Implement MobileBottomNav for native thumb-optimized usage
- Simplify Header overlay on mobile
- Add dynamic max() padding to ReferencesSlider for mobile view
- Condense vertical spacing in Footer for mobile devices
- Update layout.tsx to pad main content for bottom nav
2026-05-12 17:02:58 +02:00
1023ef978e feat: assign unique hero videos to each page to avoid duplicates
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 43s
Build & Deploy / 🧪 QA (push) Successful in 2m39s
Build & Deploy / 🏗️ Build (push) Successful in 4m48s
Build & Deploy / 🚀 Deploy (push) Successful in 56s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m1s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-12 15:46:20 +02:00
2a1f6864c9 fix(deploy): use latest gatekeeper image instead of outdated testing tag
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 35s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 2m52s
Build & Deploy / 🚀 Deploy (push) Successful in 43s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 59s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-12 15:20:20 +02:00
fa9fb60dec fix(deploy): allow insecure internal fetches for gatekeeper images
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 35s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 2m50s
Build & Deploy / 🚀 Deploy (push) Successful in 43s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 58s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-12 15:06:41 +02:00
3abe4f419f fix(deploy): fix gatekeeper network to allow DB access
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 35s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 2m51s
Build & Deploy / 🚀 Deploy (push) Successful in 42s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 58s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-12 14:50:39 +02:00
ba3a711d5b fix(deploy): remove testing.e-tib.com from TRAEFIK_HOST to prevent ACME challenge failure
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 37s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 2m46s
Build & Deploy / 🚀 Deploy (push) Successful in 43s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 58s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-05-12 14:43:42 +02:00
29 changed files with 601 additions and 175 deletions

View File

@@ -65,7 +65,7 @@ jobs:
TARGET="testing"
IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing"
TRAEFIK_HOST="test.${DOMAIN}, testing.${DOMAIN}"
TRAEFIK_HOST="test.${DOMAIN}"
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TARGET="production"

10
.npmrc
View File

@@ -1,7 +1,7 @@
@mintel:registry=https://npm.infra.mintel.me
//npm.infra.mintel.me/:_authToken=YTJlYzk0ZDk1MzRiMTRmNmQxZWNkMTBkNmFhNTg4Y2I6MWVkMjdjNzE3NDVkMzhmMmQ4MDEyODEwNmJjZmQyYTkwMjY3M2VjZA==
//npm.infra.mintel.me/:always-auth=true
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=YTJlYzk0ZDk1MzRiMTRmNmQxZWNkMTBkNmFhNTg4Y2I6MWVkMjdjNzE3NDVkMzhmMmQ4MDEyODEwNmJjZmQyYTkwMjY3M2VjZA==
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=3b9e7c4da038461efd0c6fd006d432d2ace5cb14
//git.infra.mintel.me/api/packages/mmintel/npm/:always-auth=true
//registry.infra.mintel.me/api/packages/mmintel/npm/:_authToken=YTJlYzk0ZDk1MzRiMTRmNmQxZWNkMTBkNmFhNTg4Y2I6MWVkMjdjNzE3NDVkMzhmMmQ4MDEyODEwNmJjZmQyYTkwMjY3M2VjZA==
//registry.infra.mintel.me/api/packages/mmintel/npm/:_authToken=3b9e7c4da038461efd0c6fd006d432d2ace5cb14
//registry.infra.mintel.me/api/packages/mmintel/npm/:always-auth=true
//npm.infra.mintel.me/:_authToken=3b9e7c4da038461efd0c6fd006d432d2ace5cb14
//npm.infra.mintel.me/:always-auth=true

View File

@@ -131,7 +131,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
<Badge variant="accent" className="mb-4 md:mb-6">
{t('badge')}
</Badge>
<Heading level={1} className="text-white mb-0">
<Heading level={1} variant="white" className="mb-0">
{pageData.frontmatter.title}
</Heading>
</div>
@@ -139,7 +139,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
</section>
{/* Main Content Area */}
<div className="container mx-auto px-4 py-16 md:py-24">
<Container className="py-16 md:py-24">
<div className="max-w-4xl mx-auto">
{/* Excerpt/Lead paragraph if available */}
{pageData.frontmatter.excerpt && (
@@ -180,7 +180,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
</div>
</div>
</div>
</div>
</Container>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { Footer } from '@/components/layout/Footer';
import { Header } from '@/components/layout/Header';
import { MobileBottomNav } from '@/components/layout/MobileBottomNav';
import JsonLd from '@/components/JsonLd';
import SkipLink from '@/components/SkipLink';
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
@@ -17,6 +18,7 @@ import { cookies } from 'next/headers';
import { TransitionProvider } from '@/components/providers/TransitionProvider';
import { PageTransitionShutter } from '@/components/providers/PageTransitionShutter';
import { InitialLoader } from '@/components/providers/InitialLoader';
import { CorporateBackground } from '@/components/decorations/CorporateBackground';
const inter = Inter({
subsets: ['latin'],
@@ -174,17 +176,19 @@ export default async function Layout(props: {
{/* Preload critical hero video */}
<link rel="preload" as="video" href="/assets/videos/web/hero-bohrung.mp4" type="video/mp4" />
</head>
<body className="flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white">
<body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white">
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<TransitionProvider>
<CorporateBackground />
<InitialLoader shouldShowLoader={!hasSeenLoader} />
<PageTransitionShutter />
<SkipLink />
<Header navLinks={navLinks} />
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
<main
id="main-content"
className="flex-grow animate-fade-in overflow-visible"
className="flex-grow animate-fade-in overflow-visible pb-[90px] md:pb-0"
tabIndex={-1}
>
{children}

19
app/[locale]/template.tsx Normal file
View File

@@ -0,0 +1,19 @@
'use client';
import { motion } from 'framer-motion';
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20, filter: 'blur(10px)' }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
transition={{
duration: 0.7,
ease: [0.16, 1, 0.3, 1],
}}
className="w-full h-full"
>
{children}
</motion.div>
);
}

View File

@@ -15,6 +15,17 @@
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
@keyframes bg-pulse-15 {
0%, 90%, 100% { opacity: 0.04; }
95% { opacity: 0.10; filter: brightness(1.2); }
}
@keyframes bg-pulse-20 {
0%, 90%, 100% { opacity: 0.07; }
95% { opacity: 0.14; filter: brightness(1.2); }
}
.animate-bg-pulse-15 { animation: bg-pulse-15 12s infinite ease-in-out; }
.animate-bg-pulse-20 { animation: bg-pulse-20 12s infinite ease-in-out; }
}
/* Base Styles */

View File

@@ -3,6 +3,7 @@
import * as React from 'react';
import { motion, Variants } from 'framer-motion';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
import { LogoArcs } from '@/components/ui/LogoArcs';
export interface BenefitItem {
id: string;
@@ -100,13 +101,32 @@ export function BenefitGrid({ badge, title, description, benefits }: BenefitGrid
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
>
{benefits.map((benefit) => (
<motion.div
key={benefit.id}
variants={itemVariants}
className="group relative overflow-hidden bg-neutral-50/50 border border-neutral-200/60 rounded-3xl p-8 transition-all duration-500 hover:bg-white hover:border-primary/30 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1"
>
<HoverShineOverlay shineColor="via-primary/5" />
{benefits.map((benefit, index) => {
const bgPositionClasses = [
"-bottom-[30%] -right-[30%]",
"-top-[30%] -left-[30%]",
"-bottom-[30%] -left-[30%]"
];
const animationClasses = [
"animate-[spin_60s_linear_infinite]",
"animate-[spin_80s_linear_infinite]",
"animate-[spin_100s_linear_infinite]"
];
const positionClass = bgPositionClasses[index % bgPositionClasses.length];
const animationClass = animationClasses[index % animationClasses.length];
return (
<motion.div
key={benefit.id}
variants={itemVariants}
className="group relative overflow-hidden bg-neutral-50/50 border border-neutral-200/60 rounded-3xl p-8 transition-all duration-500 hover:bg-white hover:border-primary/30 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1"
>
<HoverShineOverlay shineColor="via-primary/5" />
{/* Subtle Background Logo Arcs */}
<div className={`absolute ${positionClass} w-[120%] h-[120%] z-0 opacity-0 group-hover:opacity-[0.03] transition-opacity duration-1000 pointer-events-none mix-blend-multiply`}>
<LogoArcs className={`w-full h-full text-primary ${animationClass}`} />
</div>
<div className="relative z-10 flex items-start gap-4">
<div className="relative shrink-0 w-12 h-12 flex items-center justify-center bg-primary/10 text-primary rounded-xl transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
@@ -125,7 +145,8 @@ export function BenefitGrid({ badge, title, description, benefits }: BenefitGrid
</div>
</div>
</motion.div>
))}
);
})}
</motion.div>
</div>
</section>

View File

@@ -6,6 +6,9 @@ import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
import { Container } from '@/components/ui/Container';
import { LogoArcs } from '@/components/ui/LogoArcs';
interface CompetenceBentoGridProps {
badge?: string;
@@ -58,7 +61,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
if (!isMounted) {
return (
<section className="py-24 bg-neutral text-neutral-dark overflow-hidden relative">
<div className="container">
<Container>
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
<div className="max-w-2xl">
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
@@ -68,20 +71,20 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[220px]">
{/* Simplified static grid */}
</div>
</div>
</Container>
</section>
);
}
return (
<section className="py-24 bg-neutral text-neutral-dark relative overflow-hidden">
<div className="container relative z-10 px-4">
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
<Container className="relative z-10 px-0 md:px-12 lg:px-16">
<div className="flex flex-col md:flex-row justify-between items-end mb-12 px-4 md:px-0">
<div className="max-w-2xl">
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
<h3 className="font-heading text-4xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
</div>
<Button href={ctaHref} variant="ghost" className="mt-6 md:mt-0 bg-primary/5 hover:bg-primary/10">
<Button href={ctaHref} variant="ghost" className="mt-6 md:mt-0 bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0">
{ctaLabel}
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</Button>
@@ -111,7 +114,14 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
className={gridClasses}
>
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/30 blur-3xl rounded-full -mr-10 -mt-10 group-hover:bg-primary/50 transition-colors duration-500" />
{/* Subtle Background Logo Arcs */}
<div className={`absolute -bottom-[30%] -right-[30%] w-[120%] h-[120%] z-0 opacity-10 group-hover:opacity-20 transition-opacity duration-1000 pointer-events-none mix-blend-overlay`}>
<LogoArcs className={`w-full h-full text-white animate-[spin_60s_linear_infinite]`} />
</div>
<HoverShineOverlay shineColor="via-white/30" />
<AnimatedGlossyBorder color="white" className="opacity-0 group-hover:opacity-100 transition-opacity duration-700" borderWidth={1} />
<h4 className="font-bold text-xl md:text-2xl relative z-10 leading-snug">{item.title}</h4>
{item.description && <p className="text-white/60 text-sm mt-3 relative z-10">{item.description}</p>}
</motion.div>
@@ -146,6 +156,12 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
)}
<HoverShineOverlay />
<AnimatedGlossyBorder color="white" className="opacity-0 group-hover:opacity-100 transition-opacity duration-700" borderWidth={1} />
{/* Subtle Background Logo Arcs */}
<div className="absolute -top-[30%] -left-[30%] w-[120%] h-[120%] z-0 opacity-0 group-hover:opacity-[0.05] transition-opacity duration-1000 pointer-events-none mix-blend-overlay">
<LogoArcs className={`w-full h-full text-white animate-[spin_80s_linear_infinite]`} />
</div>
<div className="relative z-10 p-8 flex flex-col justify-end h-full text-white">
{item.tag && (
@@ -166,7 +182,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
);
})}
</div>
</div>
</Container>
</section>
);
}

View File

@@ -55,6 +55,7 @@ export function HeroVideo(props: HeroVideoProps) {
{videoUrl && (
<video
key={videoUrl}
className="absolute inset-0 w-full h-full object-cover z-1 pointer-events-none filter contrast-125 saturate-110 brightness-90"
src={videoUrl}
autoPlay

View File

@@ -101,7 +101,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
className={`flex gap-6 overflow-x-auto pb-12 pt-4 px-[calc((100vw-min(100%,1280px))/2)] [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
className={`flex gap-6 overflow-x-auto pb-12 pt-4 px-[max(1rem,calc((100vw-1280px)/2))] md:px-[max(2rem,calc((100vw-1280px)/2))] lg:px-[max(2.5rem,calc((100vw-1280px)/2))] [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
>
{references.map((ref, i) => {
const imgSrc = (ref.image && typeof ref.image === 'object' && ref.image.url)

View File

@@ -4,6 +4,7 @@ import * as React from 'react';
import { motion, Variants } from 'framer-motion';
import Image from 'next/image';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
interface SubCompanyTilesProps {
badge?: string;
@@ -49,6 +50,7 @@ const itemVariants: Variants = {
};
import { usePathname } from 'next/navigation';
import { LogoArcs } from '@/components/ui/LogoArcs';
export function SubCompanyTiles(props: SubCompanyTilesProps) {
const { data } = props;
@@ -91,6 +93,19 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
const isEtib = company.title.includes('E-TIB');
const isIng = company.title.includes('Ingenieur');
const isNemo = company.title.includes('NEMO');
const bgPositionClasses = [
"-bottom-[30%] -right-[30%]",
"-top-[30%] -left-[30%]",
"-bottom-[30%] -left-[30%]"
];
const animationClasses = [
"animate-[spin_60s_linear_infinite]",
"animate-[spin_80s_linear_infinite]",
"animate-[spin_100s_linear_infinite]"
];
const positionClass = bgPositionClasses[index % bgPositionClasses.length];
const animationClass = animationClasses[index % animationClasses.length];
return (
<motion.div
@@ -126,10 +141,22 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
? 'bg-gradient-to-t from-[#0a192f]/90 via-[#0a192f]/60 to-[#0a192f]/40'
: 'bg-gradient-to-t from-[#0a192f]/90 via-[#0a192f]/60 to-[#0a192f]/40 group-hover:from-primary/90 group-hover:to-primary/20'
}`} />
{/* Subtle Background Logo Arcs */}
<div className={`absolute ${positionClass} w-[120%] h-[120%] z-10 opacity-[0.02] group-hover:opacity-[0.08] transition-opacity duration-1000 pointer-events-none mix-blend-overlay`}>
<LogoArcs className={`w-full h-full text-white ${animationClass}`} />
</div>
{/* Premium Shine Sweep Effect (Industrial Reflection) */}
<HoverShineOverlay shineColor="via-white/30" />
{/* Glossy Animated Border */}
<AnimatedGlossyBorder
color={isCurrent ? "primary" : "white"}
className={`transition-opacity duration-700 ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
borderWidth={1}
/>
{/* Current Site Indicator Badge */}
{isCurrent && (
<div className="absolute top-6 right-6 z-20">

View File

@@ -0,0 +1,188 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { motion, useScroll, useTransform, useSpring } from 'framer-motion';
import { LogoArcs } from '@/components/ui/LogoArcs';
function MagneticRing({
children,
className,
pullStrength = 30, // How strong the pull is (higher = moves closer to mouse)
pullRadius = 600 // Distance from center at which it starts pulling
}: {
children: React.ReactNode,
className?: string,
pullStrength?: number,
pullRadius?: number
}) {
const ref = useRef<HTMLDivElement>(null);
// Spring physics for extremely smooth, organic movement
const x = useSpring(0, { stiffness: 40, damping: 25, mass: 1 });
const y = useSpring(0, { stiffness: 40, damping: 25, mass: 1 });
useEffect(() => {
// Only run on client with mouse
if (window.matchMedia("(pointer: coarse)").matches) return;
const handleMouseMove = (e: MouseEvent) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distX = e.clientX - centerX;
const distY = e.clientY - centerY;
const distance = Math.sqrt(distX * Math.pow(distX, 2) + Math.pow(distY, 2)); // Fix: correct pythagoras
// Calculate actual distance correctly
const actualDistance = Math.sqrt(distX * distX + distY * distY);
if (actualDistance < pullRadius) {
// Exponential falloff for natural magnetic feel
const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2);
// Target offset (fraction of the distance based on strength)
x.set(distX * pullFactor * (pullStrength / 100));
y.set(distY * pullFactor * (pullStrength / 100));
} else {
// Return to origin smoothly
x.set(0);
y.set(0);
}
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [x, y, pullRadius, pullStrength]);
return (
<motion.div
ref={ref}
style={{ x, y }}
className={className}
>
{children}
</motion.div>
);
}
export function CorporateBackground() {
const { scrollYProgress } = useScroll();
// Stronger parallax offsets for visible movement
const y1 = useTransform(scrollYProgress, [0, 1], [0, -400]);
const y2 = useTransform(scrollYProgress, [0, 1], [0, 500]);
const y3 = useTransform(scrollYProgress, [0, 1], [0, -600]);
const y4 = useTransform(scrollYProgress, [0, 1], [0, 350]);
const y5 = useTransform(scrollYProgress, [0, 1], [0, -500]);
return (
<div className="absolute inset-0 z-[50] overflow-hidden pointer-events-none" aria-hidden="true">
{/* 1. Top Right - Medium, rotating moderately */}
<motion.div
style={{
y: y1,
animationDelay: '0s',
maskImage: 'radial-gradient(circle at center, black 10%, transparent 60%)',
WebkitMaskImage: 'radial-gradient(circle at center, black 10%, transparent 60%)'
}}
className="absolute top-[0%] right-0 translate-x-[40%] w-[400px] h-[400px] md:w-[700px] md:h-[700px] animate-bg-pulse-20"
>
<MagneticRing pullRadius={700} pullStrength={40} className="w-full h-full">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 40, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-primary"
>
<LogoArcs className="w-full h-full" />
</motion.div>
</MagneticRing>
</motion.div>
{/* 2. Quarter way down left - Smaller, rotating fast reverse */}
<motion.div
style={{
y: y2,
animationDelay: '3s',
maskImage: 'radial-gradient(circle at center, black 15%, transparent 65%)',
WebkitMaskImage: 'radial-gradient(circle at center, black 15%, transparent 65%)'
}}
className="absolute top-[20%] left-0 -translate-x-[40%] w-[300px] h-[300px] md:w-[500px] md:h-[500px] animate-bg-pulse-15"
>
<MagneticRing pullRadius={500} pullStrength={50} className="w-full h-full">
<motion.div
animate={{ rotate: -360 }}
transition={{ duration: 30, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-secondary"
>
<LogoArcs className="w-full h-full -rotate-45" />
</motion.div>
</MagneticRing>
</motion.div>
{/* 3. Halfway down right - Medium, rotating slow */}
<motion.div
style={{
y: y3,
animationDelay: '6s',
maskImage: 'radial-gradient(circle at center, black 10%, transparent 60%)',
WebkitMaskImage: 'radial-gradient(circle at center, black 10%, transparent 60%)'
}}
className="absolute top-[45%] right-0 translate-x-[30%] w-[350px] h-[350px] md:w-[600px] md:h-[600px] animate-bg-pulse-15"
>
<MagneticRing pullRadius={600} pullStrength={35} className="w-full h-full">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 60, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-primary"
>
<LogoArcs className="w-full h-full rotate-90" />
</motion.div>
</MagneticRing>
</motion.div>
{/* 4. Bottom left - Larger, rotating moderately */}
<motion.div
style={{
y: y4,
animationDelay: '9s',
maskImage: 'radial-gradient(circle at center, black 5%, transparent 60%)',
WebkitMaskImage: 'radial-gradient(circle at center, black 5%, transparent 60%)'
}}
className="absolute top-[70%] left-0 -translate-x-[40%] w-[500px] h-[500px] md:w-[800px] md:h-[800px] animate-bg-pulse-20"
>
<MagneticRing pullRadius={800} pullStrength={30} className="w-full h-full">
<motion.div
animate={{ rotate: -360 }}
transition={{ duration: 50, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-secondary"
>
<LogoArcs className="w-full h-full rotate-180" />
</motion.div>
</MagneticRing>
</motion.div>
{/* 5. Deep bottom right - Smallest, very fast */}
<motion.div
style={{
y: y5,
animationDelay: '12s',
maskImage: 'radial-gradient(circle at center, black 20%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(circle at center, black 20%, transparent 70%)'
}}
className="absolute top-[88%] right-0 translate-x-[50%] w-[250px] h-[250px] md:w-[400px] md:h-[400px] animate-bg-pulse-15"
>
<MagneticRing pullRadius={400} pullStrength={60} className="w-full h-full">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-primary"
>
<LogoArcs className="w-full h-full -rotate-12" />
</motion.div>
</MagneticRing>
</motion.div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ export function Footer({ companyInfo }: FooterProps) {
const locale = useLocale();
return (
<footer className="bg-[#050B14] text-neutral-light py-16 relative overflow-hidden border-t border-white/5">
<footer className="bg-[#050B14] text-neutral-light py-10 md:py-16 relative overflow-hidden border-t border-white/5">
{/* Subtle background tech grid */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:32px_32px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_100%)] pointer-events-none" />
@@ -26,13 +26,13 @@ export function Footer({ companyInfo }: FooterProps) {
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-1/3 h-[200px] bg-primary/20 blur-[100px] rounded-full pointer-events-none opacity-50" />
<div className="container relative z-10 grid grid-cols-1 md:grid-cols-4 lg:grid-cols-12 gap-6 lg:gap-8 mb-12">
<div className="container relative z-10 grid grid-cols-1 md:grid-cols-4 lg:grid-cols-12 gap-4 md:gap-6 lg:gap-8 mb-8 md:mb-12">
{/* Brand Column Bento */}
<div className="col-span-1 md:col-span-4 lg:col-span-5 flex flex-col justify-between bg-white/[0.02] border border-white/5 rounded-3xl p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden">
<div className="col-span-1 md:col-span-4 lg:col-span-5 flex flex-col justify-between bg-white/[0.02] border border-white/5 rounded-3xl p-6 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
<div className="space-y-6 relative z-10">
<TransitionLink href={`/${locale}`} className="relative block h-14 w-48 mb-6 group/logo">
<div className="space-y-4 md:space-y-6 relative z-10">
<TransitionLink href={`/${locale}`} className="relative block h-12 md:h-14 w-40 md:w-48 mb-4 md:mb-6 group/logo">
<Image
src="/assets/logo-white.png"
alt="E-TIB Gruppe"
@@ -48,7 +48,7 @@ export function Footer({ companyInfo }: FooterProps) {
</div>
{companyInfo && (
<div className="text-white/70 text-sm space-y-4 mt-12 relative z-10">
<div className="text-white/70 text-sm space-y-3 md:space-y-4 mt-8 md:mt-12 relative z-10">
<div className="flex gap-4 p-4 rounded-2xl bg-white/[0.03] border border-white/[0.05] hover:bg-white/[0.05] transition-colors">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
@@ -58,7 +58,7 @@ export function Footer({ companyInfo }: FooterProps) {
<p className="whitespace-pre-line font-medium text-white/90">{companyInfo.address}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-3 md:gap-4">
<a href={`mailto:${companyInfo.contactEmail}`} className="flex flex-col gap-2 p-4 rounded-2xl bg-white/[0.03] border border-white/[0.05] hover:bg-white/[0.08] hover:border-primary/30 transition-all group/contact">
<p className="text-xs text-white/40 uppercase tracking-wider font-heading">E-Mail</p>
<p className="font-medium text-white/90 group-hover/contact:text-primary transition-colors truncate">{companyInfo.contactEmail}</p>
@@ -73,16 +73,16 @@ export function Footer({ companyInfo }: FooterProps) {
</div>
{/* Navigation Bento Grid */}
<div className="col-span-1 md:col-span-4 lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 gap-6 lg:gap-8">
<div className="col-span-1 md:col-span-4 lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 gap-4 md:gap-6 lg:gap-8">
{/* Company Column */}
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden flex flex-col">
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-6 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden flex flex-col">
<div className="absolute inset-0 bg-gradient-to-bl from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-8 text-white">
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-6 md:mb-8 text-white">
{locale === 'de' ? 'Unternehmen' : 'Company'}
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
</h4>
<ul className="space-y-4 relative z-10 flex-grow">
<ul className="space-y-2 md:space-y-4 relative z-10 flex-grow">
{[
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/kompetenzen` },
{ label: locale === 'de' ? 'Über uns' : 'About us', href: `/${locale}/ueber-uns` },
@@ -101,13 +101,13 @@ export function Footer({ companyInfo }: FooterProps) {
</div>
{/* Legal Column */}
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden flex flex-col">
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-6 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden flex flex-col">
<div className="absolute inset-0 bg-gradient-to-tr from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-8 text-white">
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-6 md:mb-8 text-white">
{locale === 'de' ? 'Rechtliches' : 'Legal'}
<span className="block w-8 h-1 bg-primary mt-3 rounded-full opacity-50" />
</h4>
<ul className="space-y-4 relative z-10 flex-grow">
<ul className="space-y-2 md:space-y-4 relative z-10 flex-grow">
{[
{ label: locale === 'de' ? 'Impressum' : 'Imprint', href: `/${locale}/impressum` },
{ label: locale === 'de' ? 'Datenschutz' : 'Privacy Policy', href: `/${locale}/datenschutz` },
@@ -123,7 +123,7 @@ export function Footer({ companyInfo }: FooterProps) {
</ul>
{/* Built Info inside Legal box for compact layout */}
<div className="mt-12 pt-6 border-t border-white/5 text-white/30 text-xs">
<div className="mt-8 pt-4 md:mt-12 md:pt-6 border-t border-white/5 text-white/30 text-xs">
<a href="https://mintel.me" target="_blank" rel="noopener noreferrer" className="hover:text-white/60 transition-colors flex items-center gap-2">
<span>Designed & Engineered by Mintel</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17l9.2-9.2M17 17V7H7"/></svg>
@@ -135,7 +135,7 @@ export function Footer({ companyInfo }: FooterProps) {
</div>
<div className="container relative z-10">
<div className="flex flex-col md:flex-row justify-between items-center gap-6 py-6 px-8 rounded-2xl bg-white/[0.02] border border-white/5 backdrop-blur-md">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 md:gap-6 py-4 px-5 md:py-6 md:px-8 rounded-2xl bg-white/[0.02] border border-white/5 backdrop-blur-md text-center md:text-left">
<p className="text-white/50 text-sm font-medium">&copy; {new Date().getFullYear()} E-TIB GmbH. {locale === 'de' ? 'Alle Rechte vorbehalten.' : 'All rights reserved.'}</p>
<div className="flex items-center gap-6">
<LanguageSwitcher isSolidMode={false} />
@@ -143,7 +143,7 @@ export function Footer({ companyInfo }: FooterProps) {
</div>
{/* Quality Badges */}
<div className="mt-6 flex flex-wrap justify-center gap-x-8 gap-y-4 text-white/30 text-xs font-medium uppercase tracking-wider">
<div className="mt-4 md:mt-6 flex flex-wrap justify-center gap-x-4 gap-y-3 md:gap-x-8 md:gap-y-4 text-white/30 text-[10px] md:text-xs font-medium uppercase tracking-wider px-2">
<div className="flex items-center gap-2"><ShieldCheck className="w-4 h-4 text-white/40" /><span>SSL Secured</span></div>
<div className="flex items-center gap-2"><Leaf className="w-4 h-4 text-primary/60" /><span>Green Hosting</span></div>
<div className="flex items-center gap-2"><Lock className="w-4 h-4 text-white/40" /><span>DSGVO Compliant</span></div>

View File

@@ -1,13 +1,11 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { TransitionLink } from '@/components/ui/TransitionLink';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { motion, AnimatePresence, useScroll, useTransform } from 'framer-motion';
import { motion, useScroll, useTransform } from 'framer-motion';
import { LanguageSwitcher } from './LanguageSwitcher';
import { Button } from '@/components/ui/Button';
export interface NavLink {
label: string;
@@ -20,7 +18,6 @@ interface HeaderProps {
export function Header({ navLinks }: HeaderProps) {
const [isScrolled, setIsScrolled] = React.useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
const [forceSolid, setForceSolid] = React.useState(false);
const pathname = usePathname() || '/';
@@ -36,13 +33,6 @@ export function Header({ navLinks }: HeaderProps) {
// Determine current locale from pathname
const currentLocale = pathname.startsWith('/en') ? 'en' : 'de';
// Helper to switch languages
const getSwitchedUrl = (newLocale: string) => {
if (!pathname) return `/${newLocale}`;
const pathWithoutLocale = pathname.replace(/^\/(en|de)/, '');
return `/${newLocale}${pathWithoutLocale === '' ? '' : pathWithoutLocale}`;
};
React.useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
@@ -68,10 +58,7 @@ export function Header({ navLinks }: HeaderProps) {
};
}, [pathname]);
// Close mobile menu on route change
React.useEffect(() => {
setIsMobileMenuOpen(false);
}, [pathname]);
// Check for force solid initially
const isSolidMode = isScrolled || forceSolid;
@@ -84,7 +71,7 @@ export function Header({ navLinks }: HeaderProps) {
}`}
>
<div
className={`relative overflow-hidden w-full max-w-[1400px] mx-auto transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] flex items-center justify-between ${
className={`relative overflow-hidden w-full max-w-7xl mx-auto transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] flex items-center justify-between ${
isSolidMode
? 'rounded-full bg-white/50 backdrop-blur-xl shadow-[0_8px_32px_rgba(0,0,0,0.08)] border border-white/40 py-2.5 px-6 md:px-8'
: 'rounded-none bg-transparent py-6 px-4 md:px-8 border border-transparent'
@@ -98,9 +85,9 @@ export function Header({ navLinks }: HeaderProps) {
/>
</div>
<TransitionLink href={`/${currentLocale}`} className={`relative flex items-center transition-all duration-500 ease-out z-50 group ${isSolidMode ? 'h-9 w-36' : 'h-12 w-48'}`}>
<TransitionLink href={`/${currentLocale}`} className={`relative flex items-center transition-all duration-500 ease-out z-50 group ${isSolidMode ? 'h-8 md:h-9 w-28 md:w-36' : 'h-10 md:h-12 w-36 md:w-48'}`}>
<Image
src={(isSolidMode || isMobileMenuOpen) ? "/assets/logo.png" : "/assets/logo-white.png"}
src={isSolidMode ? "/assets/logo.png" : "/assets/logo-white.png"}
alt="E-TIB Gruppe"
fill
className="object-contain object-left group-hover:scale-105 transition-transform duration-500 origin-left"
@@ -111,11 +98,11 @@ export function Header({ navLinks }: HeaderProps) {
<div
className="absolute inset-0 z-10 pointer-events-none transition-transform duration-500 origin-left group-hover:scale-105"
style={{
WebkitMaskImage: `url(${(isSolidMode || isMobileMenuOpen) ? "/assets/logo.png" : "/assets/logo-white.png"})`,
WebkitMaskImage: `url(${isSolidMode ? "/assets/logo.png" : "/assets/logo-white.png"})`,
WebkitMaskSize: 'contain',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'left center',
maskImage: `url(${(isSolidMode || isMobileMenuOpen) ? "/assets/logo.png" : "/assets/logo-white.png"})`,
maskImage: `url(${isSolidMode ? "/assets/logo.png" : "/assets/logo-white.png"})`,
maskSize: 'contain',
maskRepeat: 'no-repeat',
maskPosition: 'left center'
@@ -125,7 +112,8 @@ export function Header({ navLinks }: HeaderProps) {
</div>
</TransitionLink>
<nav className="relative z-10 hidden md:flex items-center gap-8 lg:gap-10">
<div className="flex items-center gap-2 md:gap-4 ml-auto md:ml-0">
<nav className="relative z-10 hidden md:flex items-center gap-8 lg:gap-10">
{navLinks && navLinks.length > 0 && navLinks.map((link) => {
const mappedUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
? `/${currentLocale}${link.url}`
@@ -137,7 +125,7 @@ export function Header({ navLinks }: HeaderProps) {
<TransitionLink
key={link.url}
href={mappedUrl}
className={`relative font-bold text-xs lg:text-sm uppercase tracking-widest transition-colors duration-300 group py-2 ${
className={`relative font-bold text-xs lg:text-sm uppercase tracking-widest transition-colors duration-300 group py-2 select-none ${
isSolidMode
? (isActive ? 'text-primary' : 'text-text-primary hover:text-primary')
: (isActive ? 'text-white' : 'text-white/80 hover:text-white')
@@ -160,93 +148,34 @@ export function Header({ navLinks }: HeaderProps) {
);
})}
<div className={`h-5 w-px transition-colors duration-300 ${isSolidMode ? 'bg-neutral-200' : 'bg-white/20'}`} />
</nav>
<LanguageSwitcher isSolidMode={isSolidMode} />
<TransitionLink
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
className={`relative ml-2 px-7 py-2.5 rounded-full font-bold text-xs lg:text-sm uppercase tracking-widest transition-all duration-500 overflow-hidden group/cta isolate ${
isSolidMode
? 'bg-primary text-white shadow-lg shadow-primary/20 hover:shadow-primary/40 hover:-translate-y-0.5 border border-transparent'
: 'bg-white/10 text-white backdrop-blur-md border border-white/30 hover:bg-white/20 hover:-translate-y-0.5 hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]'
}`}
>
<span className="relative z-10 transition-transform duration-300 group-hover/cta:scale-105 inline-block">{currentLocale === 'de' ? 'Kontakt' : 'Contact'}</span>
{/* Sleek shine sweep effect */}
<span className="absolute top-0 -left-[150%] h-[200%] w-[150%] bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg] group-hover/cta:left-[100%] transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] z-30 pointer-events-none" />
</TransitionLink>
</nav>
{/* Mobile Nav Button */}
<button
className="md:hidden z-50 p-2 relative group"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu"
>
<div className={`absolute inset-0 rounded-full transition-all duration-300 ${isSolidMode ? 'bg-neutral-100 group-hover:bg-neutral-200' : 'bg-white/10 group-hover:bg-white/20'}`} />
<svg className="relative z-10 transition-transform duration-300 group-hover:scale-110" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={(isSolidMode || isMobileMenuOpen) ? "currentColor" : "white"} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
{isMobileMenuOpen ? (
<>
<line x1="18" y1="6" x2="6" y2="18" className="origin-center animate-in fade-in zoom-in duration-300" />
<line x1="6" y1="6" x2="18" y2="18" className="origin-center animate-in fade-in zoom-in duration-300" />
</>
) : (
<>
<line x1="3" x2="21" y1="6" y2="6" className="transition-all duration-300 group-hover:translate-x-1" />
<line x1="3" x2="21" y1="12" y2="12" />
<line x1="3" x2="21" y1="18" y2="18" className="transition-all duration-300 group-hover:-translate-x-1" />
</>
)}
</svg>
</button>
{/* Right side elements (Language Switcher always visible, Contact button desktop only) */}
<div className="flex items-center gap-2 md:gap-4 lg:gap-6">
<div className={`hidden md:block h-5 w-px transition-colors duration-300 ${isSolidMode ? 'bg-neutral-200' : 'bg-white/20'}`} />
{/* Language Switcher visible on all devices */}
<div className="z-50 relative flex items-center">
<LanguageSwitcher isSolidMode={isSolidMode} />
</div>
{/* Contact Button Desktop Only */}
<TransitionLink
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
className={`relative hidden md:inline-flex ml-2 px-7 py-2.5 rounded-full font-bold text-xs lg:text-sm uppercase tracking-widest transition-all duration-500 overflow-hidden group/cta isolate select-none ${
isSolidMode
? 'bg-primary text-white shadow-lg shadow-primary/20 hover:shadow-primary/40 hover:-translate-y-0.5 border border-transparent'
: 'bg-white/10 text-white backdrop-blur-md border border-white/30 hover:bg-white/20 hover:-translate-y-0.5 hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]'
}`}
>
<span className="relative z-10 transition-transform duration-300 group-hover/cta:scale-105 inline-block">{currentLocale === 'de' ? 'Kontakt' : 'Contact'}</span>
{/* Sleek shine sweep effect */}
<span className="absolute top-0 -left-[150%] h-[200%] w-[150%] bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg] group-hover/cta:left-[100%] transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] z-30 pointer-events-none" />
</TransitionLink>
</div>
</div>
</div>
</header>
{/* Mobile Menu Overlay */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-white md:hidden pt-32 pb-8 px-6 overflow-y-auto"
>
<nav className="flex flex-col gap-6">
{navLinks && navLinks.length > 0 && navLinks.map((link) => {
const mappedUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
? `/${currentLocale}${link.url}`
: link.url;
return (
<TransitionLink
key={link.url}
href={mappedUrl}
className="text-2xl font-heading font-bold text-neutral-dark border-b border-neutral-100 pb-4"
onClick={() => setIsMobileMenuOpen(false)}
>
{link.label}
</TransitionLink>
);
})}
<TransitionLink
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
className="text-2xl font-heading font-bold text-primary border-b border-neutral-100 pb-4"
onClick={() => setIsMobileMenuOpen(false)}
>
{currentLocale === 'de' ? 'Kontakt' : 'Contact'}
</TransitionLink>
<div className="mt-8 pt-6">
<p className="text-text-secondary text-sm font-bold uppercase tracking-widest mb-4">Sprache wählen</p>
<LanguageSwitcher mobile={true} />
</div>
</nav>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -33,7 +33,7 @@ export function LanguageSwitcher({ mobile = false, isSolidMode = false }: Langua
key={loc.code}
href={getSwitchedUrl(loc.code)}
transitionMessage={currentLocale === loc.code ? undefined : `LANG_SWITCH:${currentLocObj.flag}:${loc.flag}`}
className={`group relative overflow-hidden flex items-center gap-3 px-5 py-3 rounded-xl text-lg font-semibold transition-all duration-300 ${
className={`group relative overflow-hidden flex items-center gap-3 px-5 py-3 rounded-xl text-lg font-semibold transition-all duration-300 select-none ${
currentLocale === loc.code
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-text-secondary hover:bg-neutral-50 border border-transparent'
@@ -83,7 +83,7 @@ export function LanguageSwitcher({ mobile = false, isSolidMode = false }: Langua
key={loc.code}
href={getSwitchedUrl(loc.code)}
transitionMessage={isActive ? undefined : `LANG_SWITCH:${currentLocObj.flag}:${loc.flag}`}
className={`relative z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition-all duration-300 ${linkClass}`}
className={`relative z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition-all duration-300 select-none ${linkClass}`}
aria-label={`Switch to ${loc.fullLabel}`}
>
<span className="text-sm" aria-hidden="true">{loc.flag}</span>

View File

@@ -0,0 +1,101 @@
'use client';
import * as React from 'react';
import { usePathname } from 'next/navigation';
import { TransitionLink } from '@/components/ui/TransitionLink';
import { Home, Briefcase, Users, Mail } from 'lucide-react';
import { motion } from 'framer-motion';
export interface NavLink {
label: string;
url: string;
}
interface MobileBottomNavProps {
navLinks: NavLink[];
currentLocale: string;
}
export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProps) {
const pathname = usePathname() || '/';
// Construct our bottom nav items based on the passed links
const items = React.useMemo(() => {
const homeUrl = `/${currentLocale}`;
// Find the specific links from the provided navLinks (which might be translated)
// We do a simple match based on index or keywords if needed, but since we know the order:
// 0: Kompetenzen, 1: Über uns, 2: Karriere, 3: Messen
const competenciesUrl = navLinks[0]?.url || `/${currentLocale}/kompetenzen`;
const aboutUrl = navLinks[1]?.url || `/${currentLocale}/ueber-uns`;
const contactUrl = `/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`;
return [
{
label: currentLocale === 'de' ? 'Start' : 'Home',
url: homeUrl,
icon: <Home className="w-6 h-6 mb-1" strokeWidth={2} />,
exactMatch: true
},
{
label: navLinks[0]?.label || (currentLocale === 'de' ? 'Kompetenzen' : 'Competencies'),
url: competenciesUrl,
icon: <Briefcase className="w-6 h-6 mb-1" strokeWidth={2} />
},
{
label: navLinks[1]?.label || (currentLocale === 'de' ? 'Über uns' : 'About Us'),
url: aboutUrl,
icon: <Users className="w-6 h-6 mb-1" strokeWidth={2} />
},
{
label: currentLocale === 'de' ? 'Kontakt' : 'Contact',
url: contactUrl,
icon: <Mail className="w-6 h-6 mb-1" strokeWidth={2} />
}
];
}, [currentLocale, navLinks]);
return (
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 px-2 pb-6 pt-2 bg-gradient-to-t from-white via-white to-white/90 backdrop-blur-xl border-t border-neutral-200/50 shadow-[0_-8px_32px_rgba(0,0,0,0.05)] safe-area-bottom">
<nav className="flex justify-around items-center max-w-md mx-auto">
{items.map((item) => {
// Determine if active
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
? `/${currentLocale}${item.url}`
: item.url;
const isActive = item.exactMatch
? pathname === mappedUrl
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
return (
<TransitionLink
key={item.label}
href={mappedUrl}
className={`relative flex flex-col items-center justify-center w-full min-h-[44px] py-1 transition-colors duration-300 select-none ${
isActive ? 'text-primary' : 'text-neutral-500 hover:text-neutral-900'
}`}
>
<div className="relative flex flex-col items-center justify-center">
{/* Active Icon Background */}
{isActive && (
<motion.div
layoutId="mobile-nav-active-bg"
className="absolute inset-0 bg-primary/10 rounded-full scale-150"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
)}
{/* Icon wrapper to ensure it sits above the background */}
<div className="relative z-10 transition-transform duration-300 active:scale-90">
{item.icon}
</div>
</div>
<span className="text-[10px] font-semibold mt-0.5 tracking-wide z-10">{item.label}</span>
</TransitionLink>
);
})}
</nav>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
interface TransitionContextType {
isTransitioning: boolean;
@@ -15,13 +15,39 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
const [isTransitioning, setIsTransitioning] = useState(false);
const [transitionMessage, setTransitionMessage] = useState<string | null>(null);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// We use a ref to track transition state without triggering the route change effect when it turns true
const isTransitioningRef = React.useRef(isTransitioning);
useEffect(() => {
isTransitioningRef.current = isTransitioning;
}, [isTransitioning]);
// Watch for route changes to complete the transition
useEffect(() => {
if (isTransitioningRef.current) {
// The route has actually changed in the browser, or at least the RSC payload arrived
const timer = setTimeout(() => {
setIsTransitioning(false);
setTimeout(() => setTransitionMessage(null), 300);
}, 100);
return () => clearTimeout(timer);
}
}, [pathname, searchParams]); // Run when pathname or search parameters change
const startTransition = useCallback((href: string, message: string | null = null) => {
// Wenn wir bereits navigieren, nichts tun
if (isTransitioning) return;
// Normalize paths to avoid false mismatches (e.g. /de vs /de/)
const currentPath = typeof window !== 'undefined' ? window.location.pathname.replace(/\/$/, '') || '/' : '';
const targetPath = href.split('?')[0].replace(/\/$/, '') || '/';
const currentSearch = typeof window !== 'undefined' ? window.location.search : '';
const targetSearch = href.includes('?') ? '?' + href.split('?')[1] : '';
// Aktuelle URL prüfen, falls es dieselbe ist, nicht navigieren
if (typeof window !== 'undefined' && window.location.pathname === href) {
if (currentPath === targetPath && currentSearch === targetSearch) {
// Optional: Wir könnten weich nach oben scrollen
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
@@ -34,13 +60,16 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
setTimeout(() => {
router.push(href);
// Den Shutter wieder einfahren lassen, nachdem die Seite gewechselt hat
// Next.js Route-Changes sind fast sofort da, aber wir geben dem Cache kurz Zeit
// Fallback: If for some reason the route doesn't change after 5 seconds, open the shutter anyway
setTimeout(() => {
setIsTransitioning(false);
// Message etwas verzögert zurücksetzen, damit es während dem Ausblenden nicht verschwindet
setTimeout(() => setTransitionMessage(null), 300);
}, 300);
setIsTransitioning((current) => {
if (current) {
setTimeout(() => setTransitionMessage(null), 300);
return false;
}
return current;
});
}, 5000);
}, 700);
}, [isTransitioning, router]);

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { cn } from './utils';
interface AnimatedGlossyBorderProps {
className?: string;
borderWidth?: 1 | 2;
opacity?: number;
color?: 'white' | 'primary';
}
export function AnimatedGlossyBorder({
className,
borderWidth = 1,
color = 'white'
}: AnimatedGlossyBorderProps) {
const isWhite = color === 'white';
const glowColor = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(17,124,97,0.6)';
const gradientColors = isWhite
? 'transparent 0%, rgba(255,255,255,0.1) 15%, rgba(255,255,255,0.85) 25%, rgba(255,255,255,0.1) 35%, transparent 50%, transparent 50%, rgba(255,255,255,0.1) 65%, rgba(255,255,255,0.85) 75%, rgba(255,255,255,0.1) 85%, transparent 100%'
: 'transparent 0%, rgba(17,124,97,0.1) 15%, rgba(17,124,97,0.85) 25%, rgba(17,124,97,0.1) 35%, transparent 50%, transparent 50%, rgba(17,124,97,0.1) 65%, rgba(17,124,97,0.85) 75%, rgba(17,124,97,0.1) 85%, transparent 100%';
return (
<div
className={cn('absolute inset-0 pointer-events-none rounded-[inherit] z-20 mix-blend-overlay', className)}
style={{ filter: `drop-shadow(0 0 6px ${glowColor}) drop-shadow(0 0 2px ${glowColor})` }}
>
<div
className="absolute inset-0 rounded-[inherit]"
style={{
border: `${borderWidth}px solid transparent`,
WebkitMask: 'linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude'
}}
>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[250%] aspect-square animate-[spin_8s_linear_infinite]"
style={{
background: `conic-gradient(from 0deg, ${gradientColors})`
}}
/>
</div>
</div>
);
}

View File

@@ -10,7 +10,7 @@ export function Badge({ children, className, variant = 'primary' }: { children:
};
return (
<span className={cn('inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider', variants[variant], className)}>
<span className={cn('inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider select-none', variants[variant], className)}>
{children}
</span>
);

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { TransitionLink } from '@/components/ui/TransitionLink';
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
import { cn } from './utils';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -19,7 +20,7 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
}
export const buttonBaseStyles =
'inline-flex items-center justify-center whitespace-nowrap rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
'inline-flex items-center justify-center whitespace-nowrap rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate select-none';
export const buttonVariantsMap = {
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
@@ -58,13 +59,23 @@ export function getButtonClasses(variant: keyof typeof buttonVariantsMap = 'prim
}
export function ButtonOverlay({ variant = 'primary' }: { variant?: keyof typeof buttonVariantsMap }) {
const isWhiteBg = variant === 'white' || variant === 'outline' || variant === 'ghost';
return (
<span
className={cn(
'absolute top-0 -left-[150%] h-[200%] w-[150%] bg-gradient-to-r from-transparent to-transparent skew-x-[-20deg] group-hover/btn:left-[100%] transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] z-30 pointer-events-none',
buttonShineColorsMap[variant]
<>
<span
className={cn(
'absolute top-0 -left-[150%] h-[200%] w-[150%] bg-gradient-to-r from-transparent to-transparent skew-x-[-20deg] group-hover/btn:left-[100%] transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] z-30 pointer-events-none',
buttonShineColorsMap[variant]
)}
/>
{!isWhiteBg && (
<AnimatedGlossyBorder
color="white"
className="opacity-40 group-hover/btn:opacity-100 transition-opacity duration-700"
/>
)}
/>
</>
);
}

View File

@@ -9,7 +9,7 @@ export function Label({ className, ...props }: LabelProps) {
return (
<label
className={cn(
'text-[10px] md:text-xs font-extrabold text-primary uppercase tracking-widest',
'text-[10px] md:text-xs font-extrabold text-primary uppercase tracking-widest select-none',
className
)}
{...props}

View File

@@ -0,0 +1,20 @@
import React from 'react';
// Reusable SVG component that precisely mimics the overlapping, sweeping arcs of the E-TIB logo.
// The circles are offset along a diagonal to bunch together at the bottom-left and spread apart at the top-right.
export const LogoArcs = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 200 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
{/* Outer ring */}
<circle cx="100" cy="100" r="90" strokeWidth="1.5" />
{/* Middle thicker ring */}
<circle cx="92" cy="108" r="78" strokeWidth="3.5" />
{/* Inner ring */}
<circle cx="84" cy="116" r="66" strokeWidth="1.5" />
</svg>
);

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Karriere"
title="Zukunft bauen"
description="Suchen Sie eine neue Herausforderung in einem zukunftssicheren Markt? Bei E-TIB arbeiten wir täglich daran, Deutschland zu vernetzen und die Energiewende voranzutreiben."
videoUrl="/assets/videos/web/hero-bohrung.mp4"
videoUrl="/assets/videos/web/hero-kabelpflug.mp4"
linkText="Offene Stellen"
linkHref="#stellen"
/>

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Unsere Leistungen"
title="Alles aus einer Hand"
description="Als Full-Service-Partner im Bereich Kabelleitungstiefbau decken wir das gesamte Spektrum moderner Infrastrukturprojekte ab."
videoUrl="/assets/videos/web/hero-kabelpflug.mp4"
videoUrl="/assets/videos/web/hero-bohrung.mp4"
linkText="Kontakt aufnehmen"
linkHref="/de/kontakt"
secondaryCtaLabel="Referenzen ansehen"

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Events"
title="Treffen Sie uns\nvor Ort"
description="Kommen Sie mit unseren Experten ins Gespräch. Wir sind auf den wichtigsten Branchenmessen vertreten und freuen uns auf den persönlichen Austausch mit Ihnen."
videoUrl="/assets/videos/web/hero-bohrung.mp4"
videoUrl="/assets/videos/web/hero-messe.mp4"
linkText="Zu den Terminen"
linkHref="#termine"
/>

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Careers"
title="Build the Future\nwith Us"
description="We are looking for motivated talents who want to shape the digital and energy infrastructure of tomorrow. Join the E-TIB team."
videoUrl="/assets/videos/web/hero-bahnkreuzung.mp4"
videoUrl="/assets/videos/web/hero-kabelpflug.mp4"
linkText="View Open Positions"
linkHref="#jobs"
/>

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Competencies"
title="Everything from a\nsingle source"
description="From the first planning sketch to the final documentation we are your reliable partner for complex infrastructure projects."
videoUrl="/assets/videos/web/hero-bahnkreuzung.mp4"
videoUrl="/assets/videos/web/hero-bohrung.mp4"
linkText="Get in Touch"
linkHref="/en/kontakt"
/>

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Events"
title="Meet us\nin person"
description="Get in touch with our experts. We are represented at the most important industry trade fairs and look forward to speaking with you personally."
videoUrl="/assets/videos/web/hero-bohrung.mp4"
videoUrl="/assets/videos/web/hero-messe.mp4"
linkText="View dates"
linkHref="#dates"
/>

View File

@@ -58,9 +58,10 @@ services:
etib-gatekeeper:
profiles: [ "gatekeeper" ]
image: registry.infra.mintel.me/mintel/gatekeeper:testing
image: registry.infra.mintel.me/mintel/gatekeeper:latest
restart: unless-stopped
networks:
default:
infra:
aliases:
- ${PROJECT_NAME:-klz}-gatekeeper
@@ -68,6 +69,8 @@ services:
- ${ENV_FILE:-.env}
environment:
PORT: 3000
NODE_TLS_REJECT_UNAUTHORIZED: 0
NODE_ENV: production
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"