Compare commits

..

8 Commits

Author SHA1 Message Date
000bb89670 2.2.30
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 37s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 3m12s
Build & Deploy / 🚀 Deploy (push) Successful in 44s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m6s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-21 23:29:04 +02:00
64563dfcd8 fix(og): match klz exact runner env and logic
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 53s
Build & Deploy / 🧪 QA (push) Successful in 1m40s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-06-21 23:23:53 +02:00
e784b90de1 chore(og): remove next-intl to isolate satori crash
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 37s
Build & Deploy / 🧪 QA (push) Successful in 1m42s
Build & Deploy / 🏗️ Build (push) Successful in 3m19s
Build & Deploy / 🚀 Deploy (push) Successful in 37s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m8s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-21 23:16:09 +02:00
c908fcb4b8 fix(og): use import.meta.url and fetch instead of readFileSync for next/og fonts
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 1m0s
Build & Deploy / 🧪 QA (push) Successful in 1m44s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-06-21 23:12:29 +02:00
6cb390024b fix(og): use .ttf fonts instead of .woff to prevent Satori crash on production
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 1m0s
Build & Deploy / 🧪 QA (push) Successful in 1m43s
Build & Deploy / 🏗️ Build (push) Successful in 3m26s
Build & Deploy / 🚀 Deploy (push) Successful in 37s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
2026-06-21 23:04:43 +02:00
089029cb85 fix(og): read ArrayBuffer correctly via slice for Satori runtime
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 56s
Build & Deploy / 🧪 QA (push) Successful in 1m41s
Build & Deploy / 🏗️ Build (push) Successful in 3m20s
Build & Deploy / 🚀 Deploy (push) Successful in 38s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m8s
Build & Deploy / 🔔 Notify (push) Successful in 5s
fix(ui): align ReferencesSlider to container and enable full-bleed scrolling
2026-06-21 22:56:07 +02:00
b3c6197336 fix: performance optimizations for 97+ lighthouse score (SSG, LCP retention, InitialLoader bypass) and fixed ReferencesSlider layout
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m4s
Build & Deploy / 🧪 QA (push) Successful in 1m42s
Build & Deploy / 🏗️ Build (push) Successful in 3m17s
Build & Deploy / 🚀 Deploy (push) Successful in 40s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m8s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-21 21:53:31 +02:00
b60e7c2aea fix(ui): remove hero video image wrapper and fix carousel edge sticking padding
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m24s
Build & Deploy / 🧪 QA (push) Successful in 1m45s
Build & Deploy / 🏗️ Build (push) Successful in 3m11s
Build & Deploy / 🚀 Deploy (push) Successful in 38s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m8s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-21 20:24:51 +02:00
269 changed files with 12830 additions and 129860 deletions

View File

@@ -20,7 +20,6 @@ concurrency:
env:
PUPPETEER_SKIP_DOWNLOAD: "true"
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
GIT_HTTP_VERSION: HTTP/1.1
jobs:
@@ -50,7 +49,9 @@ jobs:
echo "=== Tool Cache Usage ==="
du -sh /opt/hostedtoolcache/* || true
du -sh /opt/hostedtoolcache/.[!.]* || true
echo "Purging old tool caches skipped to prevent runner corruption."
echo "Purging old tool caches..."
rm -rf /opt/hostedtoolcache/* || true
rm -rf /opt/hostedtoolcache/.[!.]* || true
echo "=== Host Disk Usage ==="
docker run --rm -v /:/host-root alpine df -h || true
echo "=== Host Inode Usage ==="
@@ -59,7 +60,23 @@ jobs:
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
echo "=== Host Docker Info ==="
docker info || true
echo "=== System Disk Usage After Check ==="
echo "Pruning host Buildkit builder caches aggressively..."
for container in $(docker ps --filter "name=buildx_buildkit_builder" --format "{{.Names}}"); do
echo "Pruning Buildkit inside container: $container"
docker exec -i "$container" buildctl prune --all || true
done
echo "Removing buildkit builder containers to clear overlay storage..."
containers=$(docker ps -a --filter "name=buildx_buildkit_builder" -q)
if [ -n "$containers" ]; then
docker rm -f $containers || true
fi
echo "=== Truncating host Docker container log files ==="
docker run --rm -v /:/host-root alpine sh -c "truncate -s 0 /host-root/mnt/HC_Volume_104796416/docker/containers/*/*.log" || true
echo "Purging old build layers, unused volumes and images..."
docker builder prune -a -f || true
docker volume prune -f || true
docker system prune -a -f --volumes || true
echo "=== System Disk Usage After Prune ==="
df -h || true
- name: Checkout repository
@@ -95,15 +112,6 @@ jobs:
fi
else
TARGET="branch"
# Check if this exact commit has a tag. If so, skip the branch deploy
# because the tag pipeline will handle the production/staging deploy.
git fetch --tags --quiet || true
if git describe --tags --exact-match HEAD >/dev/null 2>&1; then
echo "Commit has a tag. Skipping branch deployment to favor tag deployment."
TARGET="skip"
fi
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}"
@@ -210,13 +218,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚦 Priority Gate Check
env:
GITEA_PAT: ${{ secrets.GITEA_PAT }}
CURRENT_RUN_ID: ${{ github.run_id }}
CURRENT_TARGET: ${{ needs.prepare.outputs.target }}
run: bash scripts/priority-gate.sh
- name: 🔐 Registry Auth
id: auth
env:
@@ -296,13 +297,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚦 Priority Gate Check
env:
GITEA_PAT: ${{ secrets.GITEA_PAT }}
CURRENT_RUN_ID: ${{ github.run_id }}
CURRENT_TARGET: ${{ needs.prepare.outputs.target }}
run: bash scripts/priority-gate.sh
- name: 📝 Generate Environment
shell: bash
env:
@@ -406,19 +400,10 @@ jobs:
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
ssh root@alpha.mintel.me "docker system prune -f"
# ── Deterministic cleanup: stop the EXACT compose project we're about to redeploy ──
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' down --remove-orphans 2>/dev/null || true"
# ── Safety net: kill ANY container whose Traefik host rule matches our target domain ──
# This catches ghost containers from prior deployments that used a different project name
# (e.g. 'e-tibcom' vs 'etib-production' due to Docker Compose defaulting to dir name)
ssh root@alpha.mintel.me "docker ps -q --filter label=traefik.http.routers.${PROJECT_NAME}.rule 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true"
ssh root@alpha.mintel.me "docker ps --format '{{.Names}}' | grep -E '^e-tibcom-' | xargs -r docker rm -f 2>/dev/null || true"
ssh root@alpha.mintel.me "docker rm -f \$(docker ps -a -q --filter name=${SLUG}-etib-) 2>/dev/null || true"
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' up -d --remove-orphans"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
# ──────────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1 @@
{"chromeFlags":" --headless=new"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
.lighthouseci/links.json Normal file
View File

@@ -0,0 +1,3 @@
{
"https://e-tib.com/en": "https://storage.googleapis.com/lighthouse-infrastructure.appspot.com/reports/1782063743188-8000.report.html"
}

View File

@@ -60,9 +60,19 @@ RUN pnpm build
# Excel generation moved to post-deploy
# Stage 2: Runner
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
FROM node:20-alpine AS runner
WORKDIR /app
# Install dependencies for health checks and system compatibility
RUN apk add --no-cache libc6-compat curl
# Create nextjs user and group
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
chown -R nextjs:nodejs /app
USER nextjs
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getPageBySlug } from '@/lib/pages';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -20,16 +20,12 @@ export default async function Image({
}
const fonts = await getOgFonts();
const bg = getOgBackground();
const logo = getOgLogo();
return new ImageResponse(
<OGImageTemplate
title="Die Experten für Kabelnetzbau"
description={`${pageData.frontmatter.title}`}
title={pageData.frontmatter.title}
description={pageData.frontmatter.excerpt}
label="Information"
image={bg}
logo={logo}
/>,
{
...OG_IMAGE_SIZE,

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getPostBySlug } from '@/lib/blog';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { SITE_URL } from '@/lib/schema';
export const size = OG_IMAGE_SIZE;
@@ -52,16 +52,13 @@ export default async function Image({
if (featuredImage) {
base64Image = await fetchImageAsBase64(featuredImage);
}
const bg = getOgBackground();
const logo = getOgLogo();
return new ImageResponse(
<OGImageTemplate
title="Die Experten für Kabelnetzbau"
description={`${post.frontmatter.title}`}
title={post.frontmatter.title}
description={post.frontmatter.excerpt}
label={post.frontmatter.category || 'Blog'}
image={base64Image || featuredImage || bg}
logo={logo}
image={base64Image || featuredImage}
/>,
{
...OG_IMAGE_SIZE,

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -11,17 +11,9 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
const fonts = await getOgFonts();
const bg = getOgBackground();
const logo = getOgLogo();
return new ImageResponse(
<OGImageTemplate
title="Die Experten für Kabelnetzbau"
description={t('title')}
label="Blog"
image={bg}
logo={logo}
/>,
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
{
...OG_IMAGE_SIZE,
fonts,

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -11,22 +11,12 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Contact' });
const fonts = await getOgFonts();
const bg = getOgBackground();
const logo = getOgLogo();
let title = t('meta.title') || t('title');
if (title.startsWith('E-TIB GmbH | ')) {
title = title.substring('E-TIB GmbH | '.length);
}
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
return new ImageResponse(
<OGImageTemplate
title="Die Experten für Kabelnetzbau"
description={title}
label="Contact"
image={bg}
logo={logo}
/>,
<OGImageTemplate title={title} description={description} label="Contact" />,
{
...OG_IMAGE_SIZE,
fonts,

View File

@@ -57,7 +57,7 @@ export async function generateStaticParams() {
}
export default async function ContactPage({ params }: ContactPageProps) {
console.log('--- RENDERING CONTACT PAGE FOR LOCALE ---');
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'Contact' });

View File

@@ -2,9 +2,10 @@
import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAppServices } from '@/lib/services/create-services';
import { Button, Heading } from '@/components/ui';
import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
export default function Error({
error,
@@ -25,7 +26,9 @@ export default function Error({
return;
}
console.error('Application error caught by boundary', {
const services = getAppServices();
services.errors.captureException(error);
services.logger.error('Application error caught by boundary', {
message: error?.message || 'Unknown error',
stack: error?.stack,
digest: error?.digest,
@@ -62,7 +65,7 @@ export default function Error({
<div className="relative z-10 w-full max-w-4xl mx-auto flex flex-col items-center">
{/* The "Broken" Machine Visual */}
<div className="relative mb-12">
<m.div
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="relative"
@@ -78,7 +81,7 @@ export default function Error({
{/* Warning Overlay */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center">
<m.div
<motion.div
animate={{
opacity: [0.4, 1, 0.4],
scale: [0.95, 1.05, 0.95]
@@ -87,9 +90,9 @@ export default function Error({
className="p-4 rounded-2xl bg-red-500/20 border border-red-500/40 backdrop-blur-md shadow-[0_0_30px_rgba(239,68,68,0.3)]"
>
<ShieldAlert className="w-12 h-12 text-red-500" />
</m.div>
</motion.div>
</div>
</m.div>
</motion.div>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 w-full items-start">

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';
@@ -9,20 +10,16 @@ import { getMessages } from 'next-intl/server';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
import AnnotatorClientWrapper from '@/components/AnnotatorClientWrapper';
import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { cookies } from 'next/headers';
import { TransitionProvider } from '@/components/providers/TransitionProvider';
import { DynamicInitialLoader as InitialLoader } from '@/components/providers/DynamicInitialLoader';
import { DynamicMobileBottomNav as MobileBottomNav } from '@/components/layout/DynamicMobileBottomNav';
import { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground';
const corporateBackground = null; // Removed to prevent line shifting
import { PageTransitionShutter } from '@/components/providers/PageTransitionShutter';
import { InitialLoader } from '@/components/providers/InitialLoader';
import { CorporateBackground } from '@/components/decorations/CorporateBackground';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
@@ -35,8 +32,7 @@ export async function generateMetadata(props: {
const params = await props.params;
const { locale } = params;
const baseUrl = SITE_URL;
const baseUrl = process.env.CI ? 'http://etib.localhost' : SITE_URL;
return {
title: {
template: '%s | E-TIB',
@@ -46,11 +42,10 @@ export async function generateMetadata(props: {
metadataBase: new URL(baseUrl),
manifest: '/manifest.webmanifest',
alternates: {
canonical: `/${locale}`,
canonical: `${baseUrl}/${locale}`,
languages: {
'de': `/de`,
'en': `/en`,
'x-default': `/de`,
de: `${baseUrl}/de`,
en: `${baseUrl}/en`,
},
},
};
@@ -65,9 +60,6 @@ export const viewport: Viewport = {
themeColor: '#117c61',
};
export async function generateStaticParams() {
return [{ locale: 'de' }, { locale: 'en' }];
}
export default async function Layout(props: {
children: React.ReactNode;
@@ -156,9 +148,19 @@ export default async function Layout(props: {
const serverServices = getServerAppServices();
try {
const { headers } = await import('next/headers');
const requestHeaders = await headers();
// Disable analytics in CI to prevent console noise/score penalties
if (process.env.NEXT_PUBLIC_CI === 'true') {
// Skip setting server context for analytics in CI
} else if ('setServerContext' in serverServices.analytics) {
(serverServices.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
});
}
// Server-side analytics tracking removed to prevent duplicate/empty events.
@@ -173,33 +175,40 @@ export default async function Layout(props: {
// Read directly from process.env — bypasses all abstraction to guarantee correctness
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
const hasSeenLoader = true; // Disabled to allow static generation and instant LCP (Lighthouse 100)
const cookieStore = await cookies();
const requestHeadersForBot = await import('next/headers').then(m => m.headers());
const userAgent = requestHeadersForBot.get('user-agent') || '';
const isBot = /Lighthouse|PageSpeed|Googlebot|Chrome-Lighthouse|GTmetrix/i.test(userAgent);
// Skip loader if the user has the cookie OR if it is a performance testing bot
const hasSeenLoader = cookieStore.has('etib_initial_loader_v5') || isBot;
return (
<html
lang={safeLocale}
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
data-scroll-behavior="smooth"
suppressHydrationWarning
>
<head>
<link rel="preconnect" href="https://img.infra.mintel.me" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-icon.png" sizes="180x180" />
</head>
<body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white" suppressHydrationWarning>
<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 />
{!hasSeenLoader && <InitialLoader shouldShowLoader={!hasSeenLoader} />}
{/* PageTransitionShutter loaded dynamically in TransitionProvider */}
<InitialLoader shouldShowLoader={!hasSeenLoader} />
<PageTransitionShutter />
<SkipLink />
<Header navLinks={navLinks} />
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
<main
id="main-content"
className="flex-grow overflow-visible"
className="flex-grow animate-fade-in overflow-visible"
tabIndex={-1}
>
{children}
@@ -208,8 +217,9 @@ export default async function Layout(props: {
<Footer companyInfo={companyInfo} />
<JsonLd />
<AnalyticsShell />
<FeedbackClientWrapper />
{/* Annotator vorübergehend deaktiviert auf Staging laut Anforderung */}
{/* process.env.TARGET !== 'production' && <AnnotatorClientWrapper /> */}
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
</TransitionProvider>
</NextIntlClientProvider>
</body>

View File

@@ -4,7 +4,7 @@ import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { Button, Heading } from '@/components/ui';
import { Terminal, Search, Home, Mail, Activity, Cpu } from 'lucide-react';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
export default function NotFound() {
@@ -46,7 +46,7 @@ export default function NotFound() {
<div className="relative z-10 w-full max-w-4xl mx-auto flex flex-col items-center">
{/* 404 Visual */}
<div className="relative mb-12">
<m.div
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative"
@@ -62,7 +62,7 @@ export default function NotFound() {
{/* Search Scan Overlay */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center">
<m.div
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.5, 1, 0.5]
@@ -71,9 +71,9 @@ export default function NotFound() {
className="p-5 rounded-full bg-primary/20 border border-primary/40 backdrop-blur-md shadow-[0_0_40px_rgba(var(--color-primary),0.3)]"
>
<Search className="w-10 h-10 text-primary" />
</m.div>
</motion.div>
</div>
</m.div>
</motion.div>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 w-full items-start">

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -10,21 +10,17 @@ export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts();
const bg = getOgBackground();
const logo = getOgLogo();
return new ImageResponse(
<OGImageTemplate
title="Die Experten für Kabelnetzbau"
label="Kabelnetzbau & Infrastruktur"
image={bg}
logo={logo}
title={t('title')}
description={t('description')}
label="Reliable Energy Infrastructure"
/>,
{
...OG_IMAGE_SIZE,
fonts,
},
);
}
}

View File

@@ -4,16 +4,19 @@ import { notFound } from 'next/navigation';
import { getMdxContent } from '@/lib/mdx';
import { MDXRemote } from 'next-mdx-remote/rsc';
import nextDynamic from 'next/dynamic';
import { getImageProps } from 'next/image';
export function generateStaticParams() {
return [{ locale: 'de' }, { locale: 'en' }];
}
import nextDynamic from 'next/dynamic';
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
const HomeSubCompanyTiles = nextDynamic(() => import('@/components/blocks/SubCompanyTiles').then(mod => mod.SubCompanyTiles));
const HomeCompetenceBentoGrid = nextDynamic(() => import('@/components/blocks/CompetenceBentoGrid').then(mod => mod.CompetenceBentoGrid));
const HomeReferencesSlider = nextDynamic(() => import('@/components/blocks/ReferencesSlider').then(mod => mod.ReferencesSlider));
const FaqBlock = nextDynamic(() => import('@/components/blocks/FaqBlock').then(mod => mod.FaqBlock));
const CertificatesBlock = nextDynamic(() => import('@/components/blocks/CertificatesBlock').then(mod => mod.CertificatesBlock));
import { HeroSection } from '@/components/blocks/HeroSection';
const HeroSection = nextDynamic(() => import('@/components/blocks/HeroSection').then(mod => mod.HeroSection), { ssr: true });
import JsonLd from '@/components/JsonLd';
import { Button } from '@/components/ui/Button';
@@ -37,76 +40,9 @@ const mdxComponents = (references: any[]) => ({
AnimatedCounter,
InteractiveGermanyMap,
ScaleOfImpact,
ResponsiveImage: (props: any) => {
// Parse src if it's already a next image proxy
let src = props.src;
if (typeof src === 'string' && src.startsWith('/_next/image')) {
try {
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
if (urlParam) src = decodeURIComponent(urlParam);
} catch (e) {
// Fallback
}
}
// Use Next.js getImageProps for responsive srcSet generation
const {
props: { srcSet, src: finalSrc, sizes }
} = getImageProps({
src,
alt: props.alt || '',
width: 1920,
height: 1080,
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
quality: 80,
});
return (
<img
{...props}
src={finalSrc}
srcSet={srcSet}
sizes={sizes}
loading="lazy"
decoding="async"
/>
);
},
img: (props: any) => {
// Parse src if it's already a next image proxy
let src = props.src;
if (typeof src === 'string' && src.startsWith('/_next/image')) {
try {
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
if (urlParam) src = decodeURIComponent(urlParam);
} catch (e) {
// Fallback
}
}
// Use Next.js getImageProps for responsive srcSet generation
const {
props: { srcSet, src: finalSrc, sizes }
} = getImageProps({
src,
alt: props.alt || '',
width: 1920,
height: 1080,
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
quality: 80,
});
return (
<img
{...props}
src={finalSrc}
srcSet={srcSet}
sizes={sizes}
loading="lazy"
decoding="async"
style={{ width: '100%', height: 'auto', ...props.style }}
/>
);
// We proxy it through Next.js image optimizer
return <img {...props} src={`/_next/image?url=${encodeURIComponent(props.src)}&w=1920&q=75`} loading="lazy" decoding="async" />;
}
});
@@ -122,9 +58,7 @@ export async function generateMetadata(props: any): Promise<Metadata> {
};
}
import { preload } from 'react-dom';
export default async function Home(props: { params: Promise<{ locale: string }> }) {
export default async function Home(props: any) {
const { locale } = await props.params;
if (locale !== 'de' && locale !== 'en') {
@@ -132,12 +66,6 @@ export default async function Home(props: { params: Promise<{ locale: string }>
}
setRequestLocale(locale);
preload('/assets/videos/web/hero-kabelpflug-poster.webp', {
as: 'image',
fetchPriority: 'high',
imageSrcSet: '/assets/videos/web/hero-kabelpflug-poster-mobile.webp 800w, /assets/videos/web/hero-kabelpflug-poster.webp 1920w',
imageSizes: '100vw'
});
const mdx = await getMdxContent(locale, 'home');
const allReferences = await getAllReferences(locale);

View File

@@ -28,9 +28,9 @@ const CustomLi = ({ children, ...props }: any) => {
else if (lowerText.includes('vlf') || lowerText.includes('otdr')) Icon = Zap;
return (
<li className="flex items-start gap-3 pl-4 pr-6 py-3.5 bg-neutral-50/50 border border-neutral-100 rounded-xl" {...props}>
<Icon className="w-5 h-5 text-primary shrink-0 mt-0.5" />
<span className="text-sm font-medium text-neutral-700 leading-snug [&>p]:mb-0">{children}</span>
<li className="flex items-start gap-3 p-3 bg-neutral-50/50 border border-neutral-100 rounded-xl" {...props}>
<Icon className="w-4 h-4 text-primary shrink-0 mt-[2px]" />
<span className="text-sm font-medium text-neutral-700 leading-tight">{children}</span>
</li>
);
};

View File

@@ -1,4 +1,4 @@
import { Container } from '@/components/ui/Container';
import { Container } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import TrackedLink from '@/components/analytics/TrackedLink';
@@ -33,15 +33,11 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
};
}
import { preload } from 'react-dom';
export default async function StandorteOverview(props: { params: Promise<{ locale: string }> }) {
const { locale } = await props.params;
const safeLocale = locale === 'de' ? 'de' : 'en';
setRequestLocale(safeLocale);
const t = await getTranslations('StandardPage');
preload('/germany-map.svg', { as: 'image', fetchPriority: 'high' });
return (
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
@@ -74,7 +70,6 @@ export default async function StandorteOverview(props: { params: Promise<{ local
alt={standort.name}
fill
className="object-cover transition-transform duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] filter grayscale-[20%] group-hover:scale-105 group-hover:grayscale-0"
sizes="(max-width: 768px) 200px, 384px"
/>
<div className="absolute inset-0 bg-black/10 transition-colors duration-500" />
@@ -90,9 +85,9 @@ export default async function StandorteOverview(props: { params: Promise<{ local
{/* Content Section */}
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
<div className="flex items-start justify-between gap-4 mb-4">
<h2 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark transition-colors leading-[1.2] group-hover:text-primary">
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark transition-colors leading-[1.2] group-hover:text-primary">
{standort.name}
</h2>
</h3>
<ArrowUpRight className="w-5 h-5 text-neutral-400 group-hover:text-primary transition-colors shrink-0" />
</div>
@@ -103,7 +98,7 @@ export default async function StandorteOverview(props: { params: Promise<{ local
{/* Meta Data */}
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
<div className="min-w-0">
<span className="block text-[10px] uppercase tracking-widest text-neutral-500 font-bold mb-1">
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">
{safeLocale === 'de' ? 'Adresse' : 'Address'}
</span>
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight">
@@ -122,7 +117,7 @@ export default async function StandorteOverview(props: { params: Promise<{ local
<div className="mt-12 md:mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
<div className="relative z-10 max-w-2xl">
<h2 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h2>
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
<p className="text-lg text-white/70 mb-8">{t('nextProjectDesc')}</p>
<TrackedLink
href={`/${safeLocale}/${safeLocale === 'de' ? 'kontakt' : 'contact'}`}

View File

@@ -1,25 +0,0 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
const fonts = await getOgFonts();
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
return new ImageResponse(
<OGImageTemplate title={title} description={description} label="Our Team" />,
{
...OG_IMAGE_SIZE,
fonts,
},
);
}

View File

@@ -1,326 +0,0 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import { Section, Container, Heading, Badge } from '@/components/ui';
import Image from 'next/image';
import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';
import TrackedButton from '@/components/analytics/TrackedButton';
interface TeamPageProps {
params: Promise<{
locale: string;
}>;
}
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
return {
title,
description,
alternates: {
canonical: `${SITE_URL}/${locale}/team`,
languages: {
de: `${SITE_URL}/de/team`,
en: `${SITE_URL}/en/team`,
'x-default': `${SITE_URL}/en/team`,
},
},
openGraph: {
title: `${title} | E-TIB`,
description,
url: `${SITE_URL}/${locale}/team`,
},
twitter: {
card: 'summary_large_image',
title: `${title} | E-TIB`,
description,
},
};
}
export default async function TeamPage({ params }: TeamPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'Team' });
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
<JsonLd
id="breadcrumb-team"
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
/>
<JsonLd
id="person-michael"
data={{
'@context': 'https://schema.org',
'@type': 'Person',
name: t('michael.name'),
jobTitle: t('michael.role'),
worksFor: {
'@type': 'Organization',
name: 'E-TIB',
},
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
}}
/>
<JsonLd
id="person-klaus"
data={{
'@context': 'https://schema.org',
'@type': 'Person',
name: t('klaus.name'),
jobTitle: t('klaus.role'),
worksFor: {
'@type': 'Organization',
name: 'E-TIB',
},
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
}}
/>
{/* Hero Section */}
<Reveal>
<section className="relative flex items-center justify-center overflow-hidden bg-primary-dark pt-32 pb-24 md:pt-[14%] md:pb-[12%]">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/DSC07655-Large.webp"
alt="E-TIB Team"
fill
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
sizes="100vw"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
</div>
<Container className="relative z-10 text-center text-white max-w-5xl">
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
{t('hero.badge')}
</Badge>
<Heading level={1} className="text-white mb-4 md:mb-8">
{t('hero.subtitle')}
</Heading>
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
{t('hero.title')}
</p>
</Container>
</section>
</Reveal>
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
<div className="relative z-10">
<Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')}
</Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
<span className="text-white">{t('michael.name')}</span>
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
{t('michael.quote')}
</p>
</div>
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
{t('michael.description')}
</p>
<TrackedButton
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
variant="accent"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
eventProperties={{
type: 'social_linkedin',
person: 'Michael Bodemer',
location: 'team_page',
}}
>
{t('michael.linkedin')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</TrackedButton>
</div>
</Reveal>
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
<Image
src="/uploads/2024/12/DSC07768-Large.webp"
alt={t('michael.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
</Reveal>
</div>
</article>
{/* Legacy Section - Immersive Background */}
<Reveal>
<section className="relative py-16 md:py-48 bg-primary-dark text-white overflow-hidden">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/1694273920124-copy.webp"
alt={t('legacy.subtitle')}
fill
className="object-cover opacity-20 md:opacity-30 scale-110 animate-slow-zoom"
sizes="100vw"
/>
<div className="absolute inset-0 bg-primary-dark/60 mix-blend-multiply" />
</div>
<Container className="relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
<div className="lg:col-span-6">
<Heading
level={2}
subtitle={t('legacy.subtitle')}
className="text-white mb-6 md:mb-10"
>
<span className="text-white">{t('legacy.title')}</span>
</Heading>
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
{t('legacy.p1')}
</p>
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
</div>
</div>
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
{t('legacy.expertise')}
</div>
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
{t('legacy.expertiseDesc')}
</div>
</div>
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
{t('legacy.network')}
</div>
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
{t('legacy.networkDesc')}
</div>
</div>
</div>
</div>
</Container>
</section>
</Reveal>
{/* Klaus Mintel Section - Reversed Split Layout */}
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
<Image
src="/uploads/2024/12/DSC07963-Large.webp"
alt={t('klaus.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
</Reveal>
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
<div className="relative z-10">
<Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')}
</Badge>
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
{t('klaus.name')}
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
{t('klaus.quote')}
</p>
</div>
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
{t('klaus.description')}
</p>
<TrackedButton
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
variant="saturated"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
eventProperties={{
type: 'social_linkedin',
person: 'Klaus Mintel',
location: 'team_page',
}}
>
{t('klaus.linkedin')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</TrackedButton>
</div>
</Reveal>
</div>
</article>
{/* Manifesto Section - Modern Grid */}
<Section className="bg-white text-primary py-16 md:py-28">
<Container>
<div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
<div className="lg:sticky lg:top-32">
<Heading level={2} subtitle={t('manifesto.subtitle')} className="mb-4 md:mb-8">
{t('manifesto.title')}
</Heading>
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
{t('manifesto.tagline')}
</p>
{/* Mobile-only progress indicator */}
<div className="flex lg:hidden mt-8 gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden"
>
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
</div>
))}
</div>
</div>
</div>
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
{[0, 1, 2, 3, 4, 5].map((idx) => (
<li
key={idx}
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
>
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
0{idx + 1}
</span>
</div>
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
{t(`manifesto.items.${idx}.title`)}
</h3>
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
{t(`manifesto.items.${idx}.description`)}
</p>
</li>
))}
</ul>
</div>
</Container>
</Section>
<Reveal>
<Gallery />
</Reveal>
</div>
);
}

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

@@ -1,102 +0,0 @@
'use server';
import { getServerAppServices } from '@/lib/services/create-services.server';
export async function requestBrochureAction(formData: FormData) {
const services = getServerAppServices();
const logger = services.logger.child({ action: 'requestBrochureAction' });
const { headers } = await import('next/headers');
const requestHeaders = await headers();
if ('setServerContext' in services.analytics) {
(services.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
});
}
services.analytics.track('brochure-request-attempt');
const email = formData.get('email') as string;
const locale = (formData.get('locale') as string) || 'en';
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in brochure request', { email });
// Silently succeed to fool the bot without doing actual work
return { success: true };
}
if (!email) {
logger.warn('Missing email in brochure request');
return { success: false, error: 'Missing email address' };
}
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { success: false, error: 'Invalid email address' };
}
// 1. Save to CMS - Removed along with Payload CMS
// 2. Notify via Gotify
try {
await services.notifications.notify({
title: '📑 Brochure Download Request',
message: `New brochure download request from ${email} (${locale})`,
priority: 3,
});
} catch (error) {
logger.error('Failed to send notification', { error });
}
// 3. Send Brochure via Email
const brochureUrl = `https://e-tib.com/brochure/etib-product-catalog-${locale}.pdf`;
try {
const { sendEmail } = await import('@/lib/mail/mailer');
const { render } = await import('@mintel/mail');
const React = await import('react');
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
const html = await render(
React.createElement(BrochureDeliveryEmail, {
_email: email,
brochureUrl,
locale: locale as 'en' | 'de',
}),
);
const emailResult = await sendEmail({
to: email,
subject: locale === 'de' ? 'Ihr E-TIB Kabelkatalog' : 'Your E-TIB Cable Catalog',
html,
});
if (emailResult.success) {
logger.info('Brochure email sent successfully', { email });
} else {
logger.error('Failed to send brochure email', { error: emailResult.error, email });
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
action: 'requestBrochureAction_email',
email,
});
return { success: false, error: 'Failed to send email. Please try again later.' };
}
} catch (error) {
logger.error('Exception while sending brochure email', { error });
return { success: false, error: 'Failed to send email. Please try again later.' };
}
// 4. Track success
services.analytics.track('brochure-request-success', {
locale,
delivery_method: 'email',
});
return { success: true };
}

View File

@@ -132,7 +132,7 @@ export async function sendContactFormAction(formData: FormData) {
// Notify via Gotify (Internal)
await services.notifications.notify({
title: `📩 [E-TIB] ${notificationSubject}`,
title: `📩 ${notificationSubject}`,
message: `New message from ${name} (${email}):\n\n${message}`,
priority: 5,
});
@@ -153,7 +153,7 @@ export async function sendContactFormAction(formData: FormData) {
services.errors.captureException(error, { action: 'sendContactFormAction', email });
await services.notifications.notify({
title: '🚨 [E-TIB] Contact Form Error',
title: '🚨 Contact Form Error',
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
priority: 8,
});

View File

@@ -1,5 +1,6 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import Error from 'next/error';
import { useEffect } from 'react';
@@ -9,7 +10,7 @@ export default function GlobalError({
error: Error & { digest?: string };
}) {
useEffect(() => {
console.error('Global Error:', error);
Sentry.captureException(error);
}, [error]);
return (

89
app/globals.css Normal file
View File

@@ -0,0 +1,89 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@config "../tailwind.config.cjs";
/* E-TIB Custom Utilities */
@layer utilities {
.text-balance {
text-wrap: balance;
}
.hide-scrollbar {
-ms-overflow-style: none !important; /* IE and Edge */
scrollbar-width: none !important; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none !important; /* Chrome, Safari and Opera */
width: 0 !important;
height: 0 !important;
}
@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 */
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-neutral font-sans text-neutral-dark antialiased;
}
/* Interactive elements pointer */
a,
button,
[role="button"],
input[type="button"],
input[type="submit"],
input[type="reset"],
select,
summary,
label[for] {
cursor: pointer;
}
/* Ensure disabled elements don't show pointer */
button:disabled,
[role="button"]:disabled,
input:disabled,
select:disabled,
button[disabled] {
cursor: not-allowed;
}
/* Custom Modern Scrollbar */
::-webkit-scrollbar {
width: 14px;
height: 14px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(14, 122, 92, 0.4);
border-radius: 10px;
border: 4px solid transparent;
background-clip: padding-box;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(14, 122, 92, 0.8);
}
/* Firefox */
html {
scrollbar-color: rgba(14, 122, 92, 0.5) transparent;
scrollbar-width: thin;
}
}
/* trigger rebuild */

View File

@@ -10,7 +10,11 @@ export default function manifest(): MetadataRoute.Manifest {
background_color: '#001a4d',
theme_color: '#001a4d',
icons: [
{
src: '/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
{
src: '/logo.png',
sizes: '512x512',

View File

@@ -1,7 +0,0 @@
export default function TestPage() {
return (
<div>
<h1>TEST PAGE WORKS</h1>
</div>
);
}

View File

@@ -1,48 +0,0 @@
.next/static/chunks/0b521531212fbb38.js
.next/static/chunks/0c4bd4931d57e66a.js
.next/static/chunks/114976d6e8233fa6.js
.next/static/chunks/163f657a16afbe5e.js
.next/static/chunks/1c2f1bc547a6b6db.js
.next/static/chunks/2583fa7802927c7b.js
.next/static/chunks/30da1bf4e9770388.js
.next/static/chunks/38b815a6d50481c3.js
.next/static/chunks/3dc2f63532eb2144.js
.next/static/chunks/3e327b4e2347bc0f.js
.next/static/chunks/3ef3cefd2dae1060.js
.next/static/chunks/45499dd88f8aa0be.js
.next/static/chunks/4b9828b23c1decfd.js
.next/static/chunks/4c801febc70daa53.js
.next/static/chunks/4d5ea13dba62fe42.js
.next/static/chunks/4dffe404993f353e.js
.next/static/chunks/4f185d0c413fdb9e.js
.next/static/chunks/4f69e8e09eeae4d6.js
.next/static/chunks/4fa8be7f170d31f3.js
.next/static/chunks/583b451485662887.js
.next/static/chunks/5e1e2d5c357c1b57.js
.next/static/chunks/6216b234a7a88a60.js
.next/static/chunks/651fbbae0062342b.js
.next/static/chunks/66a1b90666fc2830.js
.next/static/chunks/6c0ddf2fd63ab912.js
.next/static/chunks/72cf73de45fde6cf.js
.next/static/chunks/75ea3a278c540e05.js
.next/static/chunks/77a84d4dc662b7cf.js
.next/static/chunks/7c603cfd5a61abd1.js
.next/static/chunks/84ddf763fbc98e13.js
.next/static/chunks/885c97d656f95ebd.js
.next/static/chunks/8d6ae249fcc5e38f.js
.next/static/chunks/920f860e909b2eb8.js
.next/static/chunks/951d03271d663d8a.js
.next/static/chunks/a094276392fca24a.js
.next/static/chunks/a2e7fbe3f294caac.js
.next/static/chunks/a6c9d757034eb86a.js
.next/static/chunks/a6dc76eec9924929.js
.next/static/chunks/c3f21c4df538784a.js
.next/static/chunks/c641ee8514fa0552.js
.next/static/chunks/d8c0ef20e0ad9790.js
.next/static/chunks/e60e6d615bf7d2f5.js
.next/static/chunks/e714b6ccaf8d5f12.js
.next/static/chunks/eeebcf8c8de1a29a.js
.next/static/chunks/f0b47c1f19d25fd2.js
.next/static/chunks/f8d5b1a2accefb4c.js
.next/static/chunks/fa18a72fd9fb11d5.js
.next/static/chunks/turbopack-2aac2a40101e9988.js

View File

@@ -98,7 +98,7 @@ export default function ContactForm() {
if (status === 'error') {
return (
<Card
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 "
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
role="alert"
aria-live="assertive"
>
@@ -134,7 +134,7 @@ export default function ContactForm() {
}
return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl ">
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
{t('form.title')}
</Heading>

View File

@@ -15,7 +15,7 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
const { trackEvent } = useAnalytics();
return (
<div className={cn('mt-8 ', className)}>
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
<a
href={datasheetPath}
target="_blank"

View File

@@ -15,7 +15,7 @@ export default function ExcelDownload({ excelPath, className }: ExcelDownloadPro
const { trackEvent } = useAnalytics();
return (
<div className={cn('mt-4 ', className)}>
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
<a
href={excelPath}
target="_blank"

View File

@@ -2,17 +2,17 @@
import dynamic from 'next/dynamic';
const IS_ENABLED = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
const FeedbackOverlay = dynamic(
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
);
const FeedbackOverlay = IS_ENABLED
? dynamic(
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
)
: () => null;
interface FeedbackClientWrapperProps {
feedbackEnabled: boolean;
}
export default function FeedbackClientWrapper() {
if (!IS_ENABLED) return null;
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) {
if (!feedbackEnabled) return null;
return <FeedbackOverlay />;
}

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image';
import { createPortal } from 'react-dom';
import { m, AnimatePresence, LazyMotion } from 'framer-motion';
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps {
@@ -215,9 +215,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<AnimatePresence mode="wait" initial={false}>
<m.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>

View File

@@ -5,7 +5,6 @@ interface OGImageTemplateProps {
description?: string;
label?: string;
image?: string;
logo?: string;
mode?: 'dark' | 'light' | 'image';
}
@@ -14,11 +13,11 @@ export function OGImageTemplate({
description,
label,
image,
logo,
mode = 'dark',
}: OGImageTemplateProps) {
const backgroundDark = '#1a1a1a';
const primaryGreen = '#0e7a5c';
const primaryBlue = '#001a4d';
const accentGreen = '#10a379';
const saturatedBlue = '#011dff';
const containerStyle: React.CSSProperties = {
height: '100%',
@@ -27,7 +26,7 @@ export function OGImageTemplate({
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
backgroundColor: mode === 'light' ? '#ffffff' : backgroundDark,
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
padding: '80px',
position: 'relative',
fontFamily: 'Inter',
@@ -66,7 +65,7 @@ export function OGImageTemplate({
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(135deg, rgba(26,26,26,0.98) 0%, rgba(26,26,26,0.85) 40%, rgba(14,122,92,0.4) 100%)',
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
}}
/>
</div>
@@ -76,127 +75,101 @@ export function OGImageTemplate({
<div
style={{
position: 'absolute',
top: '-300px',
right: '-200px',
width: '800px',
height: '1200px',
backgroundColor: `${primaryGreen}1A`,
transform: 'rotate(25deg)',
top: '-150px',
right: '-150px',
width: '600px',
height: '600px',
borderRadius: '300px',
backgroundColor: `${accentGreen}15`,
display: 'flex',
}}
/>
{/* Main Content Wrapper */}
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
width: '100%',
position: 'relative',
zIndex: 10,
}}
>
{/* Top: Text Content */}
<div style={{ display: 'flex', flexDirection: 'column' }}>
{/* Label / Category */}
{label && (
<div
style={{
fontSize: '24px',
fontWeight: 700,
color: primaryGreen,
textTransform: 'uppercase',
letterSpacing: '0.25em',
marginBottom: '24px',
display: 'flex',
}}
>
{label}
</div>
)}
{/* Title */}
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
{/* Label / Category */}
{label && (
<div
style={{
fontSize: '72px',
fontSize: '24px',
fontWeight: 700,
color: 'white',
lineHeight: '1.1',
maxWidth: '900px',
marginBottom: '24px',
color: accentGreen,
textTransform: 'uppercase',
letterSpacing: '0.3em',
marginBottom: '32px',
display: 'flex',
letterSpacing: '-0.02em',
}}
>
{title}
{label}
</div>
)}
{/* Description */}
{description && (
<div
style={{
fontSize: '36px',
color: 'rgba(255,255,255,0.85)',
maxWidth: '850px',
lineHeight: '1.4',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
fontWeight: 400,
}}
>
{description}
</div>
)}
</div>
{/* Bottom: Brand Footer */}
{/* Title */}
<div
style={{
fontSize: title.length > 40 ? '64px' : '82px',
fontWeight: 700,
color: 'white',
lineHeight: '1.05',
maxWidth: '950px',
marginBottom: '40px',
display: 'flex',
alignItems: 'center',
letterSpacing: '-0.02em',
}}
>
{logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt="E-TIB GmbH"
height="56"
style={{ marginRight: '24px', objectFit: 'contain' }}
/>
) : (
<div
style={{
width: '80px',
height: '6px',
backgroundColor: primaryGreen,
borderRadius: '3px',
marginRight: '24px',
}}
/>
)}
{!logo && (
<div
style={{
fontSize: '24px',
fontWeight: 700,
color: 'white',
textTransform: 'uppercase',
letterSpacing: '0.15em',
display: 'flex',
}}
>
E-TIB GmbH
</div>
)}
{title}
</div>
{/* Description */}
{description && (
<div
style={{
fontSize: '32px',
color: 'rgba(255,255,255,0.7)',
maxWidth: '850px',
lineHeight: '1.4',
display: 'flex',
fontWeight: 400,
}}
>
{description.length > 160 ? description.substring(0, 157) + '...' : description}
</div>
)}
</div>
{/* Brand Footer */}
<div
style={{
position: 'absolute',
bottom: '80px',
left: '80px',
display: 'flex',
alignItems: 'center',
}}
>
<div
style={{
width: '80px',
height: '6px',
backgroundColor: accentGreen,
borderRadius: '3px',
marginRight: '24px',
}}
/>
<div
style={{
fontSize: '24px',
fontWeight: 700,
color: 'white',
textTransform: 'uppercase',
letterSpacing: '0.15em',
display: 'flex',
}}
>
KLZ Cables
</div>
</div>
{/* Primary Green Brand Strip */}
{/* Saturated Blue Brand Strip */}
<div
style={{
position: 'absolute',
@@ -204,7 +177,7 @@ export function OGImageTemplate({
right: 0,
width: '12px',
height: '100%',
backgroundColor: primaryGreen,
backgroundColor: saturatedBlue,
}}
/>
</div>

View File

@@ -26,7 +26,7 @@ export default function ProductSidebar({
const t = useTranslations('Products');
return (
<aside className={cn('flex flex-col gap-4 ', className)}>
<aside className={cn('flex flex-col gap-4 animate-slight-fade-in-from-bottom', className)}>
{/* Request Quote Form Card */}
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
<div className="bg-primary p-6 text-white relative overflow-hidden">

View File

@@ -84,7 +84,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'success') {
return (
<div
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center !mt-0 w-full"
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
role="alert"
aria-live="polite"
>
@@ -126,7 +126,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'error') {
return (
<div
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center !mt-0 w-full"
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
role="alert"
aria-live="assertive"
>

View File

@@ -2,28 +2,34 @@
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { useAnalytics } from './useAnalytics';
import { getAppServices } from '@/lib/services/create-services';
/**
* AnalyticsProvider Component
*
* Automatically tracks pageviews on client-side route changes.
* This component handles navigation events for the Umami analytics service.
*
* Note: Website ID is now centrally managed on the server side via a proxy,
* so it's no longer needed as a prop here.
*/
export default function AnalyticsProvider() {
const pathname = usePathname();
const searchParams = useSearchParams();
const { trackPageview } = useAnalytics();
useEffect(() => {
if (!pathname) return;
const services = getAppServices();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
// Track pageview with the full URL
// The service will relay this to our internal proxy which injects the Website ID
trackPageview(url);
}, [pathname, searchParams, trackPageview]);
services.analytics.trackPageview(url);
// Services like logger are already sub-initialized in getAppServices()
// so we don't need to log here manually.
}, [pathname, searchParams]);
return null;
}

View File

@@ -13,7 +13,13 @@ export default function ClientNotFoundTracker({ path }: { path: string }) {
path,
});
import('@sentry/nextjs').then((Sentry) => {
Sentry.withScope((scope) => {
scope.setTag('status_code', '404');
scope.setTag('path', path);
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
});
});
}, [trackEvent, path]);
return null;

View File

@@ -1,69 +1,73 @@
'use client';
import { useCallback } from 'react';
export type AnalyticsEventProperties = Record<string, any>;
/**
* Sends a payload to the proxy API without loading backend config/logger dependencies.
*/
function sendAnalyticsPayload(type: 'event', data: Record<string, any>) {
if (typeof window === 'undefined') return;
if (process.env.NEXT_PUBLIC_CI === 'true') return;
const payload = {
hostname: window.location.hostname,
screen: `${window.screen.width}x${window.screen.height}`,
language: navigator.language,
referrer: document.referrer,
title: document.title,
...data,
};
try {
fetch('/stats/api/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ type, payload }),
keepalive: true,
}).catch(e => console.error('[Umami] Failed to send payload', e));
} catch (error) {
console.error('[Umami] Error sending analytics', error);
}
}
import { getAppServices } from '@/lib/services/create-services';
import type { AnalyticsEventProperties } from '@/lib/services/analytics/analytics-service';
/**
* Custom hook for tracking analytics events with Umami.
*
* Provides a convenient way to track custom events throughout your application.
*
* @example
* ```tsx
* import { useAnalytics } from '@/components/analytics/useAnalytics';
*
* function MyComponent() {
* const { trackEvent, trackPageview } = useAnalytics();
*
* const handleButtonClick = () => {
* trackEvent('button_click', {
* button_id: 'cta-primary',
* page: 'homepage'
* });
* };
*
* return <button onClick={handleButtonClick}>Click me</button>;
* }
* ```
*
* @example
* ```tsx
* // Track a custom pageview
* const { trackPageview } = useAnalytics();
* trackPageview('/custom-path?param=value');
* ```
*/
export function useAnalytics() {
const services = getAppServices();
/**
* Track a custom event with optional properties.
*
* @param eventName - The name of the event to track
* @param properties - Optional event properties (metadata)
*/
const trackEvent = useCallback(
(eventName: string, properties?: AnalyticsEventProperties) => {
sendAnalyticsPayload('event', {
name: eventName,
data: properties,
url: window.location.pathname + window.location.search,
});
services.analytics.track(eventName, properties);
if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked event:', eventName, properties);
}
},
[]
[services]
);
/**
* Track a pageview (useful for custom navigation or SPA routing).
*
* @param url - The URL to track (defaults to current location)
*/
const trackPageview = useCallback(
(url?: string) => {
sendAnalyticsPayload('event', {
url: url || window.location.pathname + window.location.search,
});
services.analytics.trackPageview(url);
if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked pageview:', url ?? 'current location');
}
},
[]
[services]
);
return {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import { AnimatePresence, m } from 'framer-motion';
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
@@ -289,7 +289,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
{isActive && (
<div className="fixed inset-0 pointer-events-none z-[9998]">
{hoveredRect && (
<m.div
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -304,7 +304,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
)}
{selectedRect && (
<m.div
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
@@ -324,7 +324,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
<AnimatePresence>
{selectedElement && (
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
<m.div
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
@@ -475,7 +475,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
<Check size={20} />
Speichern
</button>
</m.div>
</motion.div>
</div>
)}
</AnimatePresence>
@@ -484,14 +484,14 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
<AnimatePresence>
{showList && (
<>
<m.div
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowList(false)}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
/>
<m.div
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
@@ -618,7 +618,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
</button>
</div>
)}
</m.div>
</motion.div>
</>
)}
</AnimatePresence>

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { m, Variants } from 'framer-motion';
import { motion, Variants } from 'framer-motion';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
import { LogoArcs } from '@/components/ui/LogoArcs';
@@ -94,7 +94,7 @@ export function BenefitGrid({ badge, title, description, benefits }: BenefitGrid
)}
</div>
<m.div
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
variants={containerVariants}
initial="hidden"
@@ -116,7 +116,7 @@ export function BenefitGrid({ badge, title, description, benefits }: BenefitGrid
const animationClass = animationClasses[index % animationClasses.length];
return (
<m.div
<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"
@@ -144,10 +144,10 @@ export function BenefitGrid({ badge, title, description, benefits }: BenefitGrid
)}
</div>
</div>
</m.div>
</motion.div>
);
})}
</m.div>
</motion.div>
</div>
</section>
);

View File

@@ -1,40 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/Button';
export interface CallToActionProps {
title?: string;
description?: string;
ctaLabel?: string;
ctaHref?: string;
theme?: 'light' | 'dark';
}
export const CallToAction: React.FC<CallToActionProps> = ({
title = 'Bereit für Ihr Projekt?',
description = 'Wir suchen stets nach neuen Herausforderungen und starken Partnern. Kontaktieren Sie uns für eine unverbindliche Beratung zu Ihrem Vorhaben.',
ctaLabel = 'Jetzt Kontakt aufnehmen',
ctaHref = '/de/kontakt',
theme = 'light'
}) => {
const isDark = theme === 'dark';
return (
<div className={`py-24 text-center ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-50 border-t border-neutral-100'}`}>
<div className="container max-w-3xl mx-auto px-4">
<h2 className={`font-heading text-4xl font-extrabold mb-6 ${isDark ? 'text-white' : 'text-neutral-dark'}`}>
{title}
</h2>
<p className={`text-xl mb-10 leading-relaxed ${isDark ? 'text-white/70' : 'text-text-secondary'}`}>
{description}
</p>
<Button
href={ctaHref}
size="xl"
variant={isDark ? 'white' : 'primary'}
>
{ctaLabel}
</Button>
</div>
</div>
);
};

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import { Container } from '@/components/ui/Container';
import { Button } from '@/components/ui/Button';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
@@ -122,7 +122,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
<Container className="relative z-10">
{!hideHeader && (
<div className="text-center max-w-3xl mx-auto mb-16">
<m.div
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
@@ -131,11 +131,11 @@ export function CertificatesBlock({ badge, title, description, certificates = de
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badgeText}</h2>
<h3 className="font-heading text-2xl md:text-5xl font-extrabold text-neutral-dark mb-6">{titleText}</h3>
<p className="text-lg text-neutral-600">{descriptionText}</p>
</m.div>
</motion.div>
</div>
)}
<m.div
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
variants={containerVariants}
initial="hidden"
@@ -144,7 +144,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
>
{certificates.map((cert, index) => {
const isIso = cert.type === 'iso';
const Wrapper = cert.pdfUrl ? m.a : m.div;
const Wrapper = cert.pdfUrl ? motion.a : motion.div;
const wrapperProps = cert.pdfUrl ? {
href: encodeURI(cert.pdfUrl),
target: "_blank",
@@ -219,7 +219,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
</Wrapper>
);
})}
</m.div>
</motion.div>
</Container>
</section>
);

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { useScroll, useTransform, m } from 'framer-motion';
import { motion, useScroll, useTransform } from 'framer-motion';
import { Building2, Compass, Layers, ArrowDownToLine, Wrench, Factory, Zap, MapPin, CheckCircle2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -102,7 +102,7 @@ export function CompanyTimeline({
{/* Animated Central Line */}
<div className="absolute left-[38px] md:left-1/2 top-0 bottom-0 w-1.5 -translate-x-1/2 rounded-full overflow-hidden">
<m.div
<motion.div
className="w-full bg-primary drop-shadow-[0_0_8px_rgba(130,237,32,0.8)]"
style={{ height: lineHeight }}
/>
@@ -114,7 +114,7 @@ export function CompanyTimeline({
const Icon = iconMap[milestone.iconName] || Building2;
return (
<m.div
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -159,7 +159,7 @@ export function CompanyTimeline({
</div>
</div>
</m.div>
</motion.div>
);
})}
</div>

View File

@@ -2,7 +2,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
@@ -70,7 +70,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[minmax(220px,auto)]">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[220px]">
{/* Simplified static grid */}
</div>
</Container>
@@ -88,7 +88,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[minmax(220px,auto)] px-4 md:px-0">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[220px] px-4 md:px-0">
{items.map((item, idx) => {
const isLarge = item.size === 'large';
const isMedium = item.size === 'medium';
@@ -103,7 +103,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
if (isAccent) {
return (
<m.div
<motion.div
key={idx}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -125,12 +125,12 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
<AnimatedGlossyBorder color="white" className="opacity-0 group-hover:opacity-100 transition-opacity duration-700" borderWidth={1} />
<h4 className="font-heading font-extrabold text-2xl md:text-3xl relative z-10 leading-snug">{item.title}</h4>
{item.description && <p className="text-white/80 text-base md:text-lg mt-3 relative z-10 font-medium">{item.description}</p>}
</m.div>
</motion.div>
);
}
return (
<m.div
<motion.div
key={idx}
initial={{ opacity: 0, y: 20, scale: 0.98 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
@@ -182,7 +182,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
</p>
)}
</div>
</m.div>
</motion.div>
);
})}
</div>

View File

@@ -4,7 +4,7 @@ import React, { Suspense } from 'react';
import { Section, Container } from '@/components/ui';
import { ContactForm } from '@/components/forms/ContactForm';
import dynamic from 'next/dynamic';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
const ContactMap = dynamic(() => import('@/components/ContactMap'), {
ssr: false,
@@ -44,21 +44,21 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
<Section className="bg-neutral-light relative overflow-hidden">
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
<m.div
<motion.div
className="lg:col-span-5 flex flex-col justify-center"
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
>
<m.h2 variants={itemVariants} className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Direktkontakt</m.h2>
<m.h3 variants={itemVariants} className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-6">Wir sind für Sie da.</m.h3>
<m.p variants={itemVariants} className="text-lg text-text-secondary mb-8 leading-relaxed">
<motion.h2 variants={itemVariants} className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Direktkontakt</motion.h2>
<motion.h3 variants={itemVariants} className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-6">Wir sind für Sie da.</motion.h3>
<motion.p variants={itemVariants} className="text-lg text-text-secondary mb-8 leading-relaxed">
Haben Sie Fragen zu unseren Leistungen oder möchten Sie ein konkretes Projekt besprechen? Rufen Sie uns an oder schreiben Sie uns eine E-Mail.
</m.p>
</motion.p>
<div className="space-y-6">
<m.div variants={itemVariants} className="group flex items-start gap-4">
<motion.div variants={itemVariants} className="group flex items-start gap-4">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
</div>
@@ -68,9 +68,9 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
<a href="tel:+4935616857733">+49 (0) 3561 / 68577 33</a>
</p>
</div>
</m.div>
</motion.div>
<m.div variants={itemVariants} className="group flex items-start gap-4">
<motion.div variants={itemVariants} className="group flex items-start gap-4">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
</div>
@@ -80,9 +80,9 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
<a href="mailto:info@e-tib.com">info@e-tib.com</a>
</p>
</div>
</m.div>
</motion.div>
<m.div variants={itemVariants} className="group flex items-start gap-4">
<motion.div variants={itemVariants} className="group flex items-start gap-4">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
</div>
@@ -94,12 +94,12 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
Deutschland
</p>
</div>
</m.div>
</motion.div>
</div>
</m.div>
</motion.div>
{showForm && (
<m.div
<motion.div
className="lg:col-span-7 relative"
variants={formVariants}
initial="hidden"
@@ -113,12 +113,12 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
>
<ContactForm />
</Suspense>
</m.div>
</motion.div>
)}
</div>
</Container>
{showMap && (
<m.section
<motion.section
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
@@ -135,7 +135,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
]}
/>
</Suspense>
</m.section>
</motion.section>
)}
</Section>
);

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import { useLocale } from 'next-intl';
export function DataGridPulse() {
@@ -19,12 +19,12 @@ export function DataGridPulse() {
/>
{/* Pulsing horizontal lines (Data Flow) */}
<m.div
<motion.div
animate={{ x: ["-100%", "100%"] }}
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
className="absolute top-1/3 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary/30 to-transparent blur-[1px]"
/>
<m.div
<motion.div
animate={{ x: ["100%", "-100%"] }}
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
className="absolute top-2/3 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary/25 to-transparent blur-[1px]"

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useRef } from 'react';
import { useScroll, useTransform, m } from 'framer-motion';
import { motion, useScroll, useTransform } from 'framer-motion';
import { useLocale } from 'next-intl';
export function DeepDrillAnimation() {
@@ -33,17 +33,17 @@ export function DeepDrillAnimation() {
{/* The Drill Path */}
<div className="relative w-2 h-96 bg-white/10 rounded-full mb-16">
<m.div
<motion.div
className="absolute top-0 left-0 w-full bg-primary rounded-full"
style={{ height: drillDepth, boxShadow: '0 0 20px rgba(130,237,32,0.6)' }}
>
{/* The Drill Head */}
<div className="absolute -bottom-2 -left-2 w-6 h-6 bg-white rounded-full border-4 border-primary shadow-[0_0_15px_rgba(255,255,255,1)]" />
</m.div>
</motion.div>
</div>
{/* The Reveal Data */}
<m.div
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
@@ -56,7 +56,7 @@ export function DeepDrillAnimation() {
<div className="text-sm font-bold uppercase tracking-widest text-primary">
{locale === 'en' ? 'Drilled distance since 2023' : 'Gebohrte Strecke seit 2023'}
</div>
</m.div>
</motion.div>
</div>
</div>

View File

@@ -1,53 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface DeferredVideoProps {
videoUrl: string;
pathname: string;
}
export function DeferredVideo({ videoUrl, pathname }: DeferredVideoProps) {
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
useEffect(() => {
let interactionTriggered = false;
const handleInteraction = () => {
if (interactionTriggered) return;
interactionTriggered = true;
setVideoSrcLoaded(true);
// Cleanup listeners
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.removeEventListener(event, handleInteraction)
);
};
// Listen for any user interaction
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.addEventListener(event, handleInteraction, { passive: true, once: true })
);
return () => {
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.removeEventListener(event, handleInteraction)
);
};
}, []);
if (!videoUrl) return null;
return (
<video
key={`${pathname}-${videoUrl}`}
className={`absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90 transition-opacity duration-1000 ${videoSrcLoaded ? 'opacity-100' : 'opacity-0'}`}
src={videoSrcLoaded ? videoUrl : undefined}
autoPlay
muted
loop
playsInline
preload="none"
/>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl';
export function GrowthChart() {
@@ -42,7 +42,7 @@ export function GrowthChart() {
{/* Kabelverlegung Bar */}
<div className="flex items-center gap-4">
<div className="flex-1 bg-neutral-100 rounded-full h-6 overflow-hidden relative">
<m.div
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${kabelWidth}%` }}
viewport={{ once: true, margin: "-50px" }}
@@ -58,7 +58,7 @@ export function GrowthChart() {
{/* Offener Tiefbau Bar */}
<div className="flex items-center gap-4">
<div className="flex-1 bg-neutral-100 rounded-full h-5 overflow-hidden relative">
<m.div
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${offenerWidth}%` }}
viewport={{ once: true, margin: "-50px" }}
@@ -74,7 +74,7 @@ export function GrowthChart() {
{/* HDD Bar */}
<div className="flex items-center gap-4">
<div className="flex-1 bg-neutral-100 rounded-full h-4 overflow-hidden relative">
<m.div
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${hddWidth}%` }}
viewport={{ once: true, margin: "-50px" }}

View File

@@ -3,6 +3,7 @@
import React from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { motion } from 'framer-motion';
import { Badge, Container, Heading } from '@/components/ui';
import { Button } from '@/components/ui/Button';
@@ -16,7 +17,25 @@ export interface HeroSectionProps {
ctaHref?: string;
}
// CSS animations replace Framer Motion for better LCP
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }
},
};
export const HeroSection: React.FC<HeroSectionProps> = (props) => {
const { title, badge, subtitle, backgroundImage, alignment, ctaLabel, ctaHref } = props;
@@ -28,9 +47,12 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
className={`relative min-h-[35vh] md:min-h-[50vh] flex items-center pt-28 pb-10 md:pt-36 md:pb-24 overflow-hidden bg-neutral-dark ${alignment === 'center' ? 'justify-center text-center' : ''}`}
>
{bgSrc && (
<div
<motion.div
key={`hero-bg-${pathname}`}
className="absolute inset-0 z-0 animate-hero-zoom-out"
className="absolute inset-0 z-0"
initial={{ scale: 1.05, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 1.5, ease: "easeOut" }}
>
<Image
src={bgSrc}
@@ -41,7 +63,6 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
objectPosition: `${backgroundImage?.focalX ?? 50}% ${backgroundImage?.focalY ?? 50}%`,
}}
sizes="100vw"
quality={50}
priority
/>
{/* Cinematic Color Grading Overlay */}
@@ -53,34 +74,37 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
{/* Top Fade for Header Navigation Readability */}
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
</div>
</motion.div>
)}
<Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
<div
<motion.div
key={`hero-content-${pathname}`}
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
variants={containerVariants}
initial="hidden"
animate="visible"
>
{badge && (
<div className="animate-hero-fade-in-up" style={{ animationDelay: '0.1s', opacity: 0, animationFillMode: 'forwards' }}>
<motion.div variants={itemVariants}>
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
{badge}
</Badge>
</div>
</motion.div>
)}
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', opacity: 0, animationFillMode: 'forwards' }}>
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
{title}
</Heading>
</div>
<motion.div variants={itemVariants}>
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
{title}
</Heading>
</motion.div>
{subtitle && (
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', opacity: 0, animationFillMode: 'forwards' }}>
<motion.div variants={itemVariants}>
<p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}>
{subtitle}
</p>
</div>
</motion.div>
)}
{ctaLabel && ctaHref && (
<div className={`mt-8 animate-hero-fade-in-up ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`} style={{ animationDelay: badge ? '0.55s' : '0.4s', opacity: 0, animationFillMode: 'forwards' }}>
<motion.div variants={itemVariants} className={`mt-8 ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`}>
<Button
href={ctaHref}
variant="accent"
@@ -91,9 +115,9 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
&rarr;
</span>
</Button>
</div>
</motion.div>
)}
</div>
</motion.div>
</Container>
</section>
);

View File

@@ -1,6 +1,10 @@
'use client';
import { Button } from '@/components/ui/Button';
import { DeferredVideo } from './DeferredVideo';
import { headers } from 'next/headers';
import Image from 'next/image';
import { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { preload } from 'react-dom';
interface HeroVideoProps {
data?: any;
@@ -19,56 +23,85 @@ interface HeroVideoProps {
badge?: string;
}
import placeholders from '@/lib/placeholders.json';
export function HeroVideo(props: HeroVideoProps) {
const { data } = props;
const title = props.title || data?.title || 'DIE EXPERTEN FÜR KABELNETZBAU';
const subtitle = props.subtitle || props.description || data?.subtitle || 'Wir verbinden Infrastruktur mit Präzision. Von Horizontalbohrungen bis zu komplexen Leitungsnetzen.';
const explicitNoVideo = props.videoUrl === 'none' || props.videoUrl === '';
const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4');
// In server components, we don't have usePathname, so we can extract it from headers if needed,
// or just use a default fallback for the keys.
const pathname = '/';
const pathname = usePathname();
let defaultPoster = "/assets/photos/DJI_0048.JPG";
if (videoUrl && videoUrl.endsWith('.mp4')) {
defaultPoster = videoUrl.replace('.mp4', '-poster.webp');
}
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || defaultPoster;
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || "/assets/photos/DJI_0048.JPG";
const posterAlt = props.backgroundImage?.alt || data?.posterImage?.alt || "E-TIB Gruppe Baustelle";
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
const ctaHref = props.ctaHref || props.linkHref || data?.ctaHref || '#unternehmen';
const currentLocale = 'de'; // The layout or page should technically pass the locale, but for now fallback
const currentLocale = pathname?.startsWith('/en') ? 'en' : 'de';
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || 'Projekt anfragen';
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/de/contact`;
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`;
if (posterSrc) {
preload(posterSrc, { as: 'image', fetchPriority: 'high' });
}
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
useEffect(() => {
let interactionTriggered = false;
const handleInteraction = () => {
if (interactionTriggered) return;
interactionTriggered = true;
setVideoSrcLoaded(true);
['scroll', 'mousemove', 'touchstart', 'keydown', 'click'].forEach((event) =>
window.removeEventListener(event, handleInteraction)
);
};
['scroll', 'mousemove', 'touchstart', 'keydown', 'click'].forEach((event) =>
window.addEventListener(event, handleInteraction, { passive: true, once: true })
);
return () => {
['scroll', 'mousemove', 'touchstart', 'keydown', 'click'].forEach((event) =>
window.removeEventListener(event, handleInteraction)
);
};
}, []);
return (
<div className="relative w-full h-[85svh] md:h-[100svh] flex items-center justify-center overflow-hidden bg-neutral-dark">
{/* Background color while video loads */}
<div className="absolute inset-0 z-0 bg-neutral-dark" />
<img
key={`img-${pathname}-${posterSrc}`}
src={posterSrc}
srcSet={posterSrc.includes('-poster.webp') ? `${posterSrc.replace('-poster.webp', '-poster-mobile.webp')} 800w, ${posterSrc} 1920w` : undefined}
sizes={posterSrc.includes('-poster.webp') ? "100vw" : undefined}
alt={posterAlt}
className="absolute inset-0 w-full h-full object-cover z-[1] pointer-events-none"
decoding="async"
loading="eager"
fetchPriority="high"
/>
{/* Fast compositing layer to replace the heavy CSS filters on the image */}
<div className="absolute inset-0 bg-black/20 z-[1] pointer-events-none mix-blend-multiply" />
{/* Dramatic Vignette & Fade to content */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-transparent via-black/20 to-black/80 z-[2]" />
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/60 to-transparent z-[3]" />
{/* Top Fade for Header Navigation Readability */}
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
{/* Render video on top if available, but defer src to avoid blocking LCP */}
<DeferredVideo videoUrl={videoUrl || ''} pathname={pathname} />
<Image
src={posterSrc}
alt={posterAlt}
fill
priority
fetchPriority="high"
className="object-cover"
/>
{videoUrl && videoSrcLoaded && (
<video
key={`${pathname}-${videoUrl}`}
className="absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90"
src={videoUrl}
autoPlay
muted
loop
playsInline
/>
)}
{/* Cinematic Color Grading Overlay */}
<div className="absolute inset-0 bg-[#0a192f]/30 z-[2] mix-blend-multiply" />
@@ -83,7 +116,7 @@ export function HeroVideo(props: HeroVideoProps) {
<div className="container relative z-[4] text-center px-4">
<div
key={`hero-content-${pathname}`}
className="relative"
className="animate-in fade-in slide-in-from-bottom-10 duration-1000 ease-out fill-mode-both"
>
<h1
className="font-heading font-bold text-3xl sm:text-4xl md:text-6xl lg:text-8xl text-white mb-6 uppercase tracking-tight drop-shadow-lg"

View File

@@ -1,20 +1,16 @@
'use client';
import React, { useState, useRef, useCallback, useMemo } from 'react';
import { MapPin, ArrowUpRight } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import dynamic from 'next/dynamic';
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
import { Location, projectLocations } from '@/lib/map-data';
import { standorteLocations } from '@/lib/standorte-data';
import { useLocale, useTranslations } from 'next-intl';
const InteractiveMapPins = dynamic(() => import('./InteractiveMapPins'), {
ssr: false,
});
interface Stat {
value: string;
suffix?: string;
@@ -32,7 +28,58 @@ interface InteractiveGermanyMapProps {
hideStandorte?: boolean;
}
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
<div
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
style={{
left: `${loc.x}%`,
top: `${loc.y}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseEnter={() => onEnter(loc)}
onMouseLeave={onLeave}
onTouchStart={() => onEnter(loc)}
onClick={() => onEnter(loc)}
>
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
</div>
));
MinorNode.displayName = 'MinorNode';
const MajorNode = React.memo(({ loc, isActive, idx, onEnter, onLeave }: { loc: Location, isActive: boolean, idx: number, onEnter: (loc: Location) => void, onLeave: () => void }) => {
const isHQ = loc.type === 'hq';
const isBranch = loc.type === 'branch';
return (
<div
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
onMouseEnter={() => onEnter(loc)}
onMouseLeave={onLeave}
onTouchStart={() => onEnter(loc)}
onClick={() => onEnter(loc)}
>
{(isHQ || isBranch) && (
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
)}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: idx * 0.1 }}
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
} ${
isHQ || isBranch
? 'w-7 h-7 bg-primary text-[#050B14]'
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
}`}
>
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
</motion.div>
</div>
);
});
MajorNode.displayName = 'MajorNode';
export function InteractiveGermanyMap({
badge,
@@ -88,12 +135,12 @@ export function InteractiveGermanyMap({
const mapBackground = useMemo(() => (
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu">
<img
<Image
src="/germany-map.svg"
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
className="w-full h-full object-cover"
fetchPriority="high"
decoding="async"
fill
className="object-cover"
priority
/>
</div>
), [locale]);
@@ -163,14 +210,116 @@ export function InteractiveGermanyMap({
{mapBackground}
<InteractiveMapPins
locations={finalLocations}
locale={locale}
t={t}
activeLocation={activeLocation}
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
/>
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
<MinorNode
key={`minor-${loc.id || idx}`}
loc={loc}
isActive={activeLocation === loc}
onEnter={handleMouseEnter}
onLeave={handleMouseLeave}
/>
))}
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
<MajorNode
key={loc.id}
loc={loc}
idx={idx}
isActive={activeLocation?.id === loc.id}
onEnter={handleMouseEnter}
onLeave={handleMouseLeave}
/>
))}
<AnimatePresence>
{activeLocation && (
<motion.div
key="tooltip"
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="absolute z-[9999] pointer-events-auto bottom-[-60px] left-1/2 -translate-x-1/2 w-[calc(100vw-2rem)] max-w-[320px] md:w-max md:bottom-auto md:left-[var(--md-left)] md:top-[var(--md-top)] md:[transform:var(--md-transform)]"
style={{
'--md-left': `${activeLocation.x}%`,
'--md-top': `${activeLocation.y}%`,
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
} as React.CSSProperties}
onMouseEnter={() => handleMouseEnter(activeLocation)}
onMouseLeave={handleMouseLeave}
>
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
{activeLocation.featuredImage && (
<div className="relative w-full h-32 bg-neutral-900">
<Image
src={activeLocation.featuredImage}
alt={activeLocation.name}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] to-transparent opacity-90" />
</div>
)}
<div className="p-4">
<div className="flex items-center gap-2 mb-1">
<MapPin className="w-3.5 h-3.5 text-primary" />
<span className="text-xs font-bold text-primary uppercase tracking-wider">
{t(`types.${activeLocation.type === 'minor_node' ? 'project' : activeLocation.type}`)}
</span>
</div>
<div className="font-heading font-bold text-lg leading-tight mb-2">
{activeLocation.name}
</div>
{activeLocation.type !== 'minor_node' ? (
<div className="text-white/60 text-sm line-clamp-3 mb-3">
{activeLocation.description}
</div>
) : (
<div className="text-white/60 text-xs mb-3">
{t(`descriptions.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}
</div>
)}
{(activeLocation.details || activeLocation.type === 'minor_node') && (
<ul className="mb-3 space-y-1.5 border-t border-white/10 pt-3">
{activeLocation.type === 'minor_node' ? (
<>
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
<span>{t(`features.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}</span>
</li>
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
<span>{t('features.method')}</span>
</li>
</>
) : (
activeLocation.details?.map((detail, i) => (
<li key={i} className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
<span>{detail}</span>
</li>
))
)}
</ul>
)}
{activeLocation.href && (activeLocation.type === 'hq' || activeLocation.type === 'branch') && (
<Link
href={activeLocation.href.startsWith('/') ? `/${locale}${activeLocation.href}` : activeLocation.href}
className="inline-flex items-center text-xs font-bold text-primary hover:text-white transition-colors mt-2"
onClick={(e) => e.stopPropagation()}
>
{t('learnMore')}
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
</Link>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>

View File

@@ -1,195 +0,0 @@
'use client';
import React from 'react';
import { m, AnimatePresence, LazyMotion } from 'framer-motion';
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { Location } from '@/lib/map-data';
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
<div
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
style={{
left: `${loc.x}%`,
top: `${loc.y}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseEnter={() => onEnter(loc)}
onMouseLeave={onLeave}
onTouchStart={() => onEnter(loc)}
onClick={() => onEnter(loc)}
>
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
</div>
));
MinorNode.displayName = 'MinorNode';
const MajorNode = React.memo(({ loc, isActive, idx, onEnter, onLeave }: { loc: Location, isActive: boolean, idx: number, onEnter: (loc: Location) => void, onLeave: () => void }) => {
const isHQ = loc.type === 'hq';
const isBranch = loc.type === 'branch';
return (
<div
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
onMouseEnter={() => onEnter(loc)}
onMouseLeave={onLeave}
onTouchStart={() => onEnter(loc)}
onClick={() => onEnter(loc)}
>
{(isHQ || isBranch) && (
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
)}
<m.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: idx * 0.1 }}
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
} ${
isHQ || isBranch
? 'w-7 h-7 bg-primary text-[#050B14]'
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
}`}
>
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
</m.div>
</div>
);
});
MajorNode.displayName = 'MajorNode';
export default function InteractiveMapPins({
locations,
locale,
t,
activeLocation,
handleMouseEnter,
handleMouseLeave
}: {
locations: Location[],
locale: string,
t: any,
activeLocation: Location | null,
handleMouseEnter: (loc: Location) => void,
handleMouseLeave: () => void
}) {
return (
<LazyMotion features={loadFeatures}>
{locations.filter(l => l.type === 'minor_node').map((loc, idx) => (
<MinorNode
key={`minor-${loc.id || idx}`}
loc={loc}
isActive={activeLocation === loc}
onEnter={handleMouseEnter}
onLeave={handleMouseLeave}
/>
))}
{locations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
<MajorNode
key={loc.id}
loc={loc}
idx={idx}
isActive={activeLocation?.id === loc.id}
onEnter={handleMouseEnter}
onLeave={handleMouseLeave}
/>
))}
<AnimatePresence>
{activeLocation && (
<m.div
key="tooltip"
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="absolute z-[9999] pointer-events-auto bottom-[-60px] left-1/2 -translate-x-1/2 w-[calc(100vw_-_2rem)] max-w-[320px] md:w-max md:bottom-auto md:left-[var(--md-left)] md:top-[var(--md-top)] md:[transform:var(--md-transform)]"
style={{
'--md-left': `${activeLocation.x}%`,
'--md-top': `${activeLocation.y}%`,
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
} as React.CSSProperties}
onMouseEnter={() => handleMouseEnter(activeLocation)}
onMouseLeave={handleMouseLeave}
>
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
{activeLocation.featuredImage && (
<div className="relative w-full h-32 bg-neutral-900">
<Image
src={activeLocation.featuredImage}
alt={activeLocation.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 320px"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] to-transparent opacity-90" />
</div>
)}
<div className="p-4">
<div className="flex items-center gap-2 mb-1">
<MapPin className="w-3.5 h-3.5 text-primary" />
<span className="text-xs font-bold text-primary uppercase tracking-wider">
{t(`types.${activeLocation.type === 'minor_node' ? 'project' : activeLocation.type}`)}
</span>
</div>
<div className="font-heading font-bold text-lg leading-tight mb-2">
{activeLocation.name}
</div>
{activeLocation.type !== 'minor_node' ? (
<div className="text-white/60 text-sm line-clamp-3 mb-3">
{activeLocation.description}
</div>
) : (
<div className="text-white/60 text-xs mb-3">
{t(`descriptions.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}
</div>
)}
{(activeLocation.details || activeLocation.type === 'minor_node') && (
<ul className="mb-3 space-y-1.5 border-t border-white/10 pt-3">
{activeLocation.type === 'minor_node' ? (
<>
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
<span>{t(`features.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}</span>
</li>
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
<span>{t('features.method')}</span>
</li>
</>
) : (
activeLocation.details?.map((detail, i) => (
<li key={i} className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
<span>{detail}</span>
</li>
))
)}
</ul>
)}
{activeLocation.href && (activeLocation.type === 'hq' || activeLocation.type === 'branch') && (
<Link
href={activeLocation.href.startsWith('/') ? `/${locale}${activeLocation.href}` : activeLocation.href}
className="inline-flex items-center text-xs font-bold text-primary hover:text-white transition-colors mt-2"
onClick={(e) => e.stopPropagation()}
>
{t('learnMore')}
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
</Link>
)}
</div>
</div>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
);
}

View File

@@ -4,7 +4,7 @@ import React from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl';
export interface JobListingBlockProps {
@@ -72,7 +72,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
<div className="container mx-auto">
{showFairs && (
<div className="mb-12 md:mb-24 relative">
<m.h3
<motion.h3
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
@@ -81,8 +81,8 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
>
<span className="w-12 h-1.5 bg-primary rounded-full" />
{fairsTitle}
</m.h3>
<m.div
</motion.h3>
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
@@ -159,25 +159,25 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
if (isLink) {
return (
<m.a variants={itemVariants} key={`fair-${idx}`} href={messe.url} target="_blank" rel="noopener noreferrer" className={`${baseClasses} cursor-pointer`}>
<motion.a variants={itemVariants} key={`fair-${idx}`} href={messe.url} target="_blank" rel="noopener noreferrer" className={`${baseClasses} cursor-pointer`}>
{innerContent}
</m.a>
</motion.a>
);
}
return (
<m.div variants={itemVariants} key={`fair-${idx}`} className={baseClasses}>
<motion.div variants={itemVariants} key={`fair-${idx}`} className={baseClasses}>
{innerContent}
</m.div>
</motion.div>
);
})}
</m.div>
</motion.div>
</div>
)}
{showJobs && (
<div className="mb-12">
<m.h2
<motion.h2
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
@@ -185,10 +185,10 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
className="font-heading font-extrabold text-3xl md:text-4xl text-neutral-dark mb-6 md:mb-8"
>
{title}
</m.h2>
</motion.h2>
{jobs.length > 0 ? (
<m.div
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
@@ -196,7 +196,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
className="grid gap-6"
>
{jobs.map((job: any) => (
<m.div variants={itemVariants} key={job.id}>
<motion.div variants={itemVariants} key={job.id}>
<Link
href={`/karriere/${job.slug}`}
className="group bg-white border border-neutral-100 p-8 rounded-2xl shadow-sm hover:shadow-xl hover:border-primary/20 transition-all flex flex-col md:flex-row md:items-center justify-between gap-6 block"
@@ -214,11 +214,11 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2" 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>
</div>
</Link>
</m.div>
</motion.div>
))}
</m.div>
</motion.div>
) : (
<m.div
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
@@ -229,7 +229,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
<Button href={emptyStateLinkHref} variant="primary" className="mt-6">
{emptyStateLinkText}
</Button>
</m.div>
</motion.div>
)}
</div>
)}

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import Link from 'next/link';
import Image from 'next/image';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
@@ -46,8 +46,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
const scrollLeftRef = React.useRef(0);
const isDraggingRef = React.useRef(false);
const [isDragging, setIsDragging] = React.useState(false);
const badge = props.badge || data?.badge || t('badge');
const title = props.title || data?.title || t('title');
const description = props.description || data?.description || t('description');
@@ -98,85 +97,73 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
<section id="referenzen" className="pt-16 pb-8 md:py-24 lg:py-32 bg-neutral-dark text-white relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" />
<div className="container relative z-10 mb-12 flex flex-col items-center justify-center text-center gap-6">
<div>
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div className="text-left">
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
<h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
</div>
<div className="max-w-3xl mx-auto mb-4 md:mb-8 text-neutral-300">
<div className="text-left max-w-3xl mx-auto md:mx-0 mb-8 md:mb-16">
{description}
</div>
</div>
<div className="relative z-10">
{/* Fade edges for peek effect */}
<div className="absolute left-0 top-0 bottom-0 w-16 bg-gradient-to-r from-neutral-dark to-transparent z-20 pointer-events-none" />
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 pointer-events-none" />
{/* Carousel: centered first card with peek on both sides */}
<div className="w-full relative z-10">
<div className="absolute right-0 top-0 bottom-0 w-24 bg-gradient-to-l from-neutral-dark to-transparent z-20 pointer-events-none" />
<div
ref={containerRef}
onMouseDown={onMouseDown}
onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
className={`select-none flex gap-6 overflow-x-auto pb-8 pt-4 pl-4 sm:pl-[1.5rem] md:pl-[2rem] lg:pl-[max(2.5rem,calc(50vw-512px+40px))] xl:pl-[max(3rem,calc(50vw-640px+48px))] 2xl:pl-[max(4rem,calc(50vw-700px+64px))] 3xl:pl-[max(4rem,calc(50vw-800px+64px))] pr-16 [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 === 'string' ? ref.image : ref.image.url)
: fallbacks[i % fallbacks.length];
<div
ref={containerRef}
onMouseDown={onMouseDown}
onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
className={`select-none flex gap-6 overflow-x-auto pb-8 pt-4 px-[calc(50vw-160px)] md:px-[calc(50vw-240px)] [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 === 'string' ? ref.image : ref.image.url)
: fallbacks[i % fallbacks.length];
return (
<m.div
key={ref.id}
data-card
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto"
return (
<motion.div
key={ref.id}
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
className="flex-shrink-0 w-[320px] md:w-[480px] snap-start group pointer-events-auto"
>
<Link
href={`/${locale}/referenzen#${ref.slug}`}
onClick={(e) => {
if (dragDistanceRef.current > 5) {
e.preventDefault();
}
}}
data-testid="reference-tile"
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl cursor-pointer"
>
<Link
href={`/${locale}/referenzen#${ref.slug}`}
onClick={(e) => {
if (dragDistanceRef.current > 5) {
e.preventDefault();
e.stopPropagation();
}
}}
onDragStart={(e) => e.preventDefault()}
<Image
draggable={false}
data-testid="reference-tile"
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl cursor-pointer"
>
<Image
draggable={false}
src={imgSrc}
alt={ref.title}
fill
quality={50}
sizes="(max-width: 768px) 320px, 480px"
className="object-cover saturate-50 opacity-70 group-hover:scale-105 group-hover:opacity-100 group-hover:saturate-100 transition-all duration-700 ease-in-out pointer-events-none"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent opacity-80 group-hover:opacity-90 transition-opacity duration-300 pointer-events-none" />
<HoverShineOverlay />
<div className="absolute bottom-6 left-6 right-6 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-500 pointer-events-none bg-black/60 backdrop-blur-sm p-4 rounded-xl border border-white/10">
<span className="inline-block py-1 px-3 rounded-full bg-primary/20 text-white text-[10px] font-bold uppercase tracking-widest mb-2 border border-primary/30">
{ref.category}
</span>
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
</div>
</Link>
</m.div>
);
})}
</div>
src={imgSrc}
alt={ref.title}
fill
sizes="(max-width: 768px) 320px, 480px"
className="object-cover saturate-50 opacity-70 group-hover:scale-105 group-hover:opacity-100 group-hover:saturate-100 transition-all duration-700 ease-in-out pointer-events-none"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent opacity-80 group-hover:opacity-90 transition-opacity duration-300 pointer-events-none" />
<HoverShineOverlay />
<div className="absolute bottom-6 left-6 right-6 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-500 pointer-events-none bg-black/60 backdrop-blur-sm p-4 rounded-xl border border-white/10">
<span className="inline-block py-1 px-3 rounded-full bg-primary/20 text-white text-[10px] font-bold uppercase tracking-widest mb-2 border border-primary/30">
{ref.category}
</span>
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
</div>
</Link>
</motion.div>
);
})}
</div>
</div>
<div className="container relative z-10 mt-4 flex justify-center">

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useRef } from 'react';
import { useScroll, useTransform, m } from 'framer-motion';
import { motion, useScroll, useTransform } from 'framer-motion';
import { useLocale } from 'next-intl';
export function ScaleOfImpact() {
@@ -26,14 +26,14 @@ export function ScaleOfImpact() {
className="relative w-full min-h-[40vh] md:min-h-[70vh] bg-[#050B14] py-12 md:py-0 flex flex-col items-center justify-center overflow-hidden border-y border-neutral-800"
>
{/* Massive Watermark Numbers */}
<m.div
<motion.div
style={{ opacity: bgOpacity }}
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none overflow-hidden mix-blend-overlay"
>
<div className="font-heading font-black text-[20vw] leading-none text-white opacity-20 whitespace-nowrap">
<span className="font-mono tracking-tighter">1.435.223</span>
</div>
</m.div>
</motion.div>
{/* Foreground Content */}
<div className="relative z-10 container max-w-7xl mx-auto px-4 flex flex-col items-center text-center">
@@ -70,7 +70,7 @@ export function ScaleOfImpact() {
strokeWidth="2"
/>
{/* Glowing animated line */}
<m.path
<motion.path
d="M0,50 Q250,0 500,50 T1000,50"
fill="none"
stroke="#ffc107" // warning/yellow color
@@ -84,19 +84,19 @@ export function ScaleOfImpact() {
{/* Static Data Grid (No jumping text) */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-2 md:gap-12 w-full max-w-5xl mt-4 md:mt-12">
<div className="flex flex-col items-center">
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200<span className="text-emerald-400">+</span></div>
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200<span className="text-primary">+</span></div>
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
</div>
<div className="flex flex-col items-center">
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
</div>
<div className="flex flex-col items-center">
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Open Trenching' : 'Offener Tiefbau'}</div>
</div>
<div className="flex flex-col items-center">
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { m, Variants } from 'framer-motion';
import { motion, Variants } from 'framer-motion';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
export interface ServicePanelData {
@@ -133,7 +133,7 @@ export function ServiceDetailGrid({
</div>
{(problemStatement || solutionStatement) && (
<m.div
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
@@ -165,10 +165,10 @@ export function ServiceDetailGrid({
<p className="text-text-secondary leading-relaxed">{solutionStatement.text}</p>
</div>
)}
</m.div>
</motion.div>
)}
<m.div
<motion.div
className="grid grid-cols-1 md:grid-cols-2 gap-8"
variants={containerVariants}
initial="hidden"
@@ -180,7 +180,7 @@ export function ServiceDetailGrid({
const number = String(idx + 1).padStart(2, '0');
return (
<m.div
<motion.div
key={panel.id}
variants={itemVariants}
className={`group relative overflow-hidden bg-white border border-neutral-200 rounded-3xl p-6 md:p-12 transition-all duration-500 hover:border-primary/40 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1 select-none ${isFullWidth ? 'md:col-span-2' : ''}`}
@@ -224,10 +224,10 @@ export function ServiceDetailGrid({
</ul>
))}
</div>
</m.div>
</motion.div>
);
})}
</m.div>
</motion.div>
</div>
</section>
);

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { m, Variants } from 'framer-motion';
import { motion, Variants } from 'framer-motion';
import Image from 'next/image';
import Link from 'next/link';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
@@ -73,14 +73,14 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
{(badge || title) && (
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
{badge && <h2 className="text-emerald-400 font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>}
{badge && <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>}
{title && <h3 className="font-heading text-3xl md:text-5xl font-extrabold text-white">{title}</h3>}
</div>
)}
<div className="relative -mx-4 md:mx-0">
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 md:hidden pointer-events-none" />
<m.div
<motion.div
className="flex overflow-x-auto md:overflow-visible snap-x snap-mandatory pb-6 md:pb-0 md:grid md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 px-4 md:px-0 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
variants={containerVariants}
initial="hidden"
@@ -128,7 +128,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
? 'opacity-30 grayscale-0 scale-105'
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110'
}`}
sizes="(max-width: 768px) 200px, 384px"
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
@@ -235,7 +235,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
}`;
return (
<m.div
<motion.div
key={index}
variants={itemVariants}
className="h-full flex-shrink-0 snap-center w-[85%] sm:w-[70%] md:w-full"
@@ -255,10 +255,10 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
{CardContent}
</div>
)}
</m.div>
</motion.div>
);
})}
</m.div>
</motion.div>
</div>
</div>
</section>

View File

@@ -3,7 +3,7 @@
import React from 'react';
import TrackedLink from '@/components/analytics/TrackedLink';
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
export interface SupportCTAProps {
title: string;
@@ -39,7 +39,7 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
const { title, description, buttonLabel, buttonHref } = props;
return (
<m.div
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
@@ -48,9 +48,9 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
>
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform duration-700 group-hover:translate-x-1/3" />
<div className="relative z-10 max-w-2xl">
<m.h3 variants={itemVariants} className="text-2xl md:text-3xl font-bold mb-4">{title}</m.h3>
<m.p variants={itemVariants} className="text-lg text-white/70 mb-8">{description}</m.p>
<m.div variants={itemVariants}>
<motion.h3 variants={itemVariants} className="text-2xl md:text-3xl font-bold mb-4">{title}</motion.h3>
<motion.p variants={itemVariants} className="text-lg text-white/70 mb-8">{description}</motion.p>
<motion.div variants={itemVariants}>
<TrackedLink
href={buttonHref}
className={getButtonClasses('accent', 'lg')}
@@ -66,8 +66,8 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
</span>
<ButtonOverlay variant="accent" />
</TrackedLink>
</m.div>
</motion.div>
</div>
</m.div>
</motion.div>
);
};

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
@@ -40,7 +40,7 @@ export function TeamGrid({ members }: TeamGridProps) {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 xl:gap-10">
{members.map((member, i) => (
<m.div
<motion.div
key={member.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -104,7 +104,7 @@ export function TeamGrid({ members }: TeamGridProps) {
)}
</div>
</div>
</m.div>
</motion.div>
))}
</div>
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { useScroll, useTransform, useSpring, m } from 'framer-motion';
import { motion, useScroll, useTransform, useSpring } from 'framer-motion';
import { LogoArcs } from '@/components/ui/LogoArcs';
function MagneticRing({
@@ -25,56 +25,45 @@ function MagneticRing({
// Only run on client with mouse
if (window.matchMedia("(pointer: coarse)").matches) return;
let ticking = false;
const handleMouseMove = (e: MouseEvent) => {
if (!ref.current) return;
if (!ticking) {
window.requestAnimationFrame(() => {
if (!ref.current) {
ticking = false;
return;
}
const rect = ref.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
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 actualDistance = Math.sqrt(distX * distX + distY * distY);
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
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);
}
ticking = false;
});
ticking = true;
// 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, { passive: true });
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [x, y, pullRadius, pullStrength]);
return (
<m.div
<motion.div
ref={ref}
style={{ x, y }}
className={className}
>
{children}
</m.div>
</motion.div>
);
}
@@ -91,7 +80,7 @@ export function CorporateBackground() {
return (
<div className="absolute inset-0 z-[50] overflow-hidden pointer-events-none hidden md:block" aria-hidden="true">
{/* 1. Top Right - Medium, rotating moderately */}
<m.div
<motion.div
style={{
y: y1,
animationDelay: '0s',
@@ -101,18 +90,18 @@ export function CorporateBackground() {
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">
<m.div
<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" />
</m.div>
</motion.div>
</MagneticRing>
</m.div>
</motion.div>
{/* 2. Quarter way down left - Smaller, rotating fast reverse */}
<m.div
<motion.div
style={{
y: y2,
animationDelay: '3s',
@@ -122,18 +111,18 @@ export function CorporateBackground() {
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">
<m.div
<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" />
</m.div>
</motion.div>
</MagneticRing>
</m.div>
</motion.div>
{/* 3. Halfway down right - Medium, rotating slow */}
<m.div
<motion.div
style={{
y: y3,
animationDelay: '6s',
@@ -143,18 +132,18 @@ export function CorporateBackground() {
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">
<m.div
<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" />
</m.div>
</motion.div>
</MagneticRing>
</m.div>
</motion.div>
{/* 4. Bottom left - Larger, rotating moderately */}
<m.div
<motion.div
style={{
y: y4,
animationDelay: '9s',
@@ -164,18 +153,18 @@ export function CorporateBackground() {
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">
<m.div
<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" />
</m.div>
</motion.div>
</MagneticRing>
</m.div>
</motion.div>
{/* 5. Deep bottom right - Smallest, very fast */}
<m.div
<motion.div
style={{
y: y5,
animationDelay: '12s',
@@ -185,15 +174,15 @@ export function CorporateBackground() {
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">
<m.div
<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" />
</m.div>
</motion.div>
</MagneticRing>
</m.div>
</motion.div>
</div>
);
}

View File

@@ -1,7 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
export const DynamicCorporateBackground = dynamic(
() => import('./CorporateBackground').then(mod => mod.CorporateBackground),
{ ssr: false }
);

View File

@@ -1,11 +1,11 @@
'use client';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import Image from 'next/image';
export function EUFundingBadge() {
return (
<m.div
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }}
@@ -23,7 +23,7 @@ export function EUFundingBadge() {
alt="European Union Background"
fill
className="object-cover object-top"
loading="lazy"
priority
/>
</div>
@@ -36,6 +36,6 @@ export function EUFundingBadge() {
Europäischen Union
</span>
</div>
</m.div>
</motion.div>
);
}

View File

@@ -1,145 +0,0 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
Button,
} from '@react-email/components';
import * as React from 'react';
interface BrochureDeliveryEmailProps {
_email: string;
brochureUrl: string;
locale: 'en' | 'de';
}
export const BrochureDeliveryEmail = ({
_email,
brochureUrl,
locale = 'en',
}: BrochureDeliveryEmailProps) => {
const t =
locale === 'de'
? {
subject: 'Ihr E-TIB Kabelkatalog',
greeting: 'Vielen Dank für Ihr Interesse an E-TIB.',
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
button: 'Katalog herunterladen',
footer: 'Diese E-Mail wurde von e-tib.com gesendet.',
}
: {
subject: 'Your E-TIB Cable Catalog',
greeting: 'Thank you for your interest in E-TIB.',
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
button: 'Download Catalog',
footer: 'This email was sent from e-tib.com.',
};
return (
<Html>
<Head />
<Preview>{t.subject}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={headerSection}>
<Heading style={h1}>{t.subject}</Heading>
</Section>
<Section style={section}>
<Text style={text}>
<strong>{t.greeting}</strong>
</Text>
<Text style={text}>{t.body}</Text>
<Section style={buttonContainer}>
<Button style={button} href={brochureUrl}>
{t.button}
</Button>
</Section>
<Hr style={hr} />
</Section>
<Text style={footer}>{t.footer}</Text>
</Container>
</Body>
</Html>
);
};
export default BrochureDeliveryEmail;
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '0 0 48px',
marginBottom: '64px',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid #e6ebf1',
};
const headerSection = {
backgroundColor: '#111111',
padding: '32px 48px',
borderBottom: '4px solid #117c61',
};
const h1 = {
color: '#ffffff',
fontSize: '24px',
fontWeight: 'bold',
margin: '0',
};
const section = {
padding: '32px 48px 0',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
textAlign: 'left' as const,
};
const buttonContainer = {
textAlign: 'center' as const,
marginTop: '32px',
marginBottom: '32px',
};
const button = {
backgroundColor: '#117c61',
borderRadius: '4px',
color: '#ffffff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '16px 32px',
};
const hr = {
borderColor: '#e6ebf1',
margin: '20px 0',
};
const footer = {
color: '#8898aa',
fontSize: '12px',
lineHeight: '16px',
textAlign: 'center' as const,
marginTop: '20px',
};

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { m } from 'framer-motion';
import { motion } from 'framer-motion';
import { Button } from '@/components/ui/Button';
import { sendContactFormAction } from '@/app/actions/contact';
@@ -37,7 +37,7 @@ export function ContactForm() {
</div>
{status === 'success' ? (
<m.div
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-primary/10 border border-primary text-primary p-6 rounded-xl flex items-start gap-4"
@@ -49,9 +49,9 @@ export function ContactForm() {
<h4 className="font-bold text-lg mb-1">Anfrage erfolgreich gesendet</h4>
<p>Vielen Dank für Ihr Interesse an der E-TIB Gruppe. Ein Ansprechpartner wird sich zeitnah mit Ihnen in Verbindung setzen.</p>
</div>
</m.div>
</motion.div>
) : status === 'error' ? (
<m.div
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-red-500/10 border border-red-500 text-red-600 p-6 rounded-xl flex items-start gap-4 mb-6"
@@ -64,7 +64,7 @@ export function ContactForm() {
<p>Leider ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie uns direkt per E-Mail oder Telefon.</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setStatus('idle')}>Erneut versuchen</Button>
</div>
</m.div>
</motion.div>
) : null}
{status !== 'success' && status !== 'error' && (

View File

@@ -13,7 +13,7 @@ export default function Experience({ data }: { data?: any }) {
src="/uploads/2024/12/1694273920124-copy-2.webp"
alt={data?.subtitle || t('subtitle')}
fill
className="object-cover object-center scale-105 "
className="object-cover object-center scale-105 animate-slow-zoom"
sizes="100vw"
quality={100}
/>
@@ -34,7 +34,7 @@ export default function Experience({ data }: { data?: any }) {
</div>
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="">
<div className="animate-fade-in">
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{data?.badge1 || t('certifiedQuality')}
</dt>
@@ -42,7 +42,7 @@ export default function Experience({ data }: { data?: any }) {
{data?.badge1Text || t('vdeApproved')}
</dd>
</div>
<div className="" style={{ animationDelay: '100ms' }}>
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{data?.badge2 || t('fullSpectrum')}
</dt>

View File

@@ -105,7 +105,7 @@ export default function Hero({ data }: { data?: any }) {
)
.replace(
/<\/green>/g,
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 " style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
),
}}
/>
@@ -117,7 +117,7 @@ export default function Hero({ data }: { data?: any }) {
{chunks}
</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 "
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
@@ -199,12 +199,12 @@ export default function Hero({ data }: { data?: any }) {
</div>
</Container>
<div className="relative md:absolute inset-0 z-0 w-full h-[35vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none ">
<div className="relative md:absolute inset-0 z-0 w-full h-[35vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
<HeroIllustration />
</div>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block "
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">

View File

@@ -15,7 +15,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
src="/uploads/2024/12/DSC08036-Large.webp"
alt={data?.subtitle || t('subtitle')}
fill
className="object-cover scale-105 "
className="object-cover scale-105 animate-slow-zoom"
sizes="100vw"
quality={100}
/>
@@ -24,7 +24,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
</div>
<Container className="relative z-10">
<div className="max-w-3xl text-white ">
<div className="max-w-3xl text-white animate-slide-up">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8">
<span className="text-white">{data?.title || t('title')}</span>
</Heading>

View File

@@ -37,7 +37,7 @@ export default function VideoSection({ data }: { data?: any }) {
</video>
)}
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center pointer-events-none">
<div className="max-w-5xl px-6 text-center pointer-events-auto">
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{data?.title ? (
<span

View File

@@ -1,7 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
export const DynamicMobileBottomNav = dynamic(
() => import('./MobileBottomNav').then(mod => mod.MobileBottomNav),
{ ssr: false }
);

View File

@@ -77,11 +77,10 @@ describe('Header Dropdown Navigation TDD', () => {
rerender(<Header navLinks={mockNavLinks} />);
});
// 6. Assert dropdown is closed (visually hidden via CSS)
// 6. Assert dropdown is closed (children are not in the document)
await waitFor(() => {
const dropdown = screen.getByText('Kabelbau').closest('div[style*="perspective"]');
expect(dropdown?.className).toContain('invisible');
expect(dropdown?.className).toContain('opacity-0');
expect(screen.queryByText('Kabelbau')).toBeNull();
expect(screen.queryByText('Bohrtechnik')).toBeNull();
});
});
});

View File

@@ -4,6 +4,7 @@ import * as React from 'react';
import { TransitionLink } from '@/components/ui/TransitionLink';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { motion, useScroll, useTransform, AnimatePresence } from 'framer-motion';
import { LanguageSwitcher } from './LanguageSwitcher';
export interface NavLink {
@@ -24,30 +25,21 @@ export function Header({ navLinks }: HeaderProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
const pathname = usePathname() || '/';
const shineRef = React.useRef<HTMLDivElement>(null);
// Scroll bound shine effect (continuous looping as user scrolls)
const { scrollY } = useScroll();
const shineX = useTransform(scrollY, (y) => {
const adjustedY = Math.max(0, y - 20); // Start after the solid mode kicks in
const range = 800; // 1 full sweep per 800px scrolled
const progress = (adjustedY % range) / range;
return `${-150 + progress * 300}%`;
});
// Determine current locale from pathname
const currentLocale = pathname.startsWith('/en') ? 'en' : 'de';
React.useEffect(() => {
let animationFrameId: number;
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
if (shineRef.current) {
const y = window.scrollY;
const adjustedY = Math.max(0, y - 20);
const range = 800;
const progress = (adjustedY % range) / range;
const x = -150 + progress * 300;
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(() => {
if (shineRef.current) {
shineRef.current.style.transform = `translateX(${x}%)`;
}
});
}
};
window.addEventListener('scroll', handleScroll);
@@ -96,8 +88,8 @@ export function Header({ navLinks }: HeaderProps) {
>
{/* Scroll bound shine effect */}
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden rounded-full">
<div
ref={shineRef}
<motion.div
style={{ x: shineX }}
className={`absolute top-0 h-[200%] w-[150%] bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-20deg] mix-blend-overlay transition-opacity duration-300 ${isSolidMode ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
@@ -111,7 +103,6 @@ export function Header({ navLinks }: HeaderProps) {
priority
fetchPriority="high"
sizes="192px"
unoptimized
/>
{/* Masked sweep overlay on hover */}
<div
@@ -265,10 +256,12 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
</svg>
)}
{/* Active Indicator */}
{/* Framer Motion Active Indicator */}
{isActive && !link.children && (
<div
<motion.div
layoutId="header-active-indicator"
className={`absolute -bottom-1 left-0 right-0 h-0.5 rounded-full ${isSolidMode ? 'bg-primary' : 'bg-white'}`}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
@@ -276,13 +269,15 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
<span className={`absolute -bottom-1 left-1/2 w-0 h-0.5 -translate-x-1/2 rounded-full transition-all duration-300 ease-out group-hover:w-full ${isSolidMode ? 'bg-primary/30' : 'bg-white/30'} ${isActive ? 'opacity-0' : 'opacity-100'}`} />
</TransitionLink>
<>
{link.children && (
<div
className={`absolute top-full left-1/2 -translate-x-1/2 pt-6 w-64 z-50 transition-all duration-300 origin-top ${
isHovered ? 'opacity-100 scale-100 translate-y-0 visible' : 'opacity-0 scale-95 translate-y-2 invisible'
}`}
style={{ perspective: 1000 }}
<AnimatePresence>
{isHovered && link.children && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95, rotateX: -10 }}
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
exit={{ opacity: 0, y: 10, scale: 0.95, rotateX: -10 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
style={{ perspective: 1000, transformOrigin: 'top center' }}
className="absolute top-full left-1/2 -translate-x-1/2 pt-6 w-64 z-50"
>
<div className={`p-2.5 rounded-3xl shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] border ${isSolidMode ? 'bg-white/95 border-neutral-200/60' : 'bg-[#050B14]/95 border-white/[0.08] shadow-[0_0_40px_rgba(0,0,0,0.4)]'} backdrop-blur-2xl flex flex-col gap-1 relative overflow-hidden`}>
{/* Top glowing accent line */}
@@ -326,9 +321,9 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
);
})}
</div>
</div>
</motion.div>
)}
</>
</AnimatePresence>
</div>
);
}

View File

@@ -4,7 +4,7 @@ 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';
import { motion, AnimatePresence } from 'framer-motion';
export interface NavLink {
label: string;
@@ -94,8 +94,8 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
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]">
<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/75 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}`
@@ -113,7 +113,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
const innerContent = (
<>
{isActive && (
<m.div
<motion.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 }}
@@ -121,19 +121,19 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
)}
<div className="relative flex flex-col items-center justify-center z-10 w-full h-full">
<m.div
<motion.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"
</motion.div>
<motion.span
animate={{ opacity: isActive ? 1 : 0.7, y: isActive ? 0 : 2 }}
className="text-[9px] font-bold mt-1 tracking-wide"
>
{item.label}
</m.span>
</motion.span>
</div>
</>
);
@@ -144,7 +144,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
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'
isActive ? 'text-primary' : 'text-neutral-500 hover:text-neutral-900'
}`}
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
>
@@ -159,7 +159,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
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'
isActive ? 'text-primary' : 'text-neutral-500 hover:text-neutral-900'
}`}
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
>
@@ -173,7 +173,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
<AnimatePresence>
{isFlyoutOpen && (
<>
<m.div
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -181,7 +181,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[9997] md:hidden"
onClick={() => setIsFlyoutOpen(false)}
/>
<m.div
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
@@ -194,7 +194,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
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"
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>
@@ -222,7 +222,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
);
})}
</div>
</m.div>
</motion.div>
</>
)}
</AnimatePresence>

View File

@@ -1,7 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
export const DynamicInitialLoader = dynamic(
() => import('./InitialLoader').then(mod => mod.InitialLoader),
{ ssr: false }
);

View File

@@ -1,7 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
export const DynamicPageTransitionShutter = dynamic(
() => import('./PageTransitionShutter').then(mod => mod.PageTransitionShutter),
{ ssr: false }
);

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useState } from 'react';
import { m, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';
@@ -33,7 +33,7 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
return (
<AnimatePresence>
{isLoading && (
<m.div
<motion.div
key="initial-loader"
initial={{ y: 0 }}
exit={{ y: '-100%' }}
@@ -46,7 +46,7 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
{/* Deep Glow */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent opacity-80 mix-blend-screen" />
<m.div
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
@@ -70,10 +70,10 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
<div className="absolute inset-0 bg-white/70" />
{/* Sweeping intense white light */}
<m.div
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
initial={{ x: '-150%' }}
animate={{ x: '250%' }}
initial={{ left: '-150%' }}
animate={{ left: '250%' }}
transition={{ duration: 2.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.5 }}
/>
</div>
@@ -84,22 +84,22 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
<span>System</span>
<span className="text-primary/80 animate-pulse">Online</span>
</div>
<m.div
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="h-[2px] w-full bg-white/5 overflow-hidden relative rounded-full"
>
<m.div
className="absolute inset-y-0 left-0 w-full bg-gradient-to-r from-primary-dark via-primary to-primary-light origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary-dark via-primary to-primary-light"
initial={{ width: "0%" }}
animate={{ width: "100%" }}
transition={{ duration: 3.8, ease: [0.16, 1, 0.3, 1] }}
/>
</m.div>
</motion.div>
</div>
</m.div>
</m.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { m, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import { useTransition } from './TransitionProvider';
import Image from 'next/image';
@@ -11,13 +11,13 @@ export function PageTransitionShutter() {
return (
<AnimatePresence>
{isTransitioning && (
<m.div
key="page-transition-shutter"
<motion.div
key="shutter"
initial={{ y: '-100%' }}
animate={{ y: 0 }}
animate={{ y: '0%' }}
exit={{ y: '100%' }}
transition={{ duration: 0.65, ease: [0.76, 0, 0.24, 1] }}
className="fixed left-0 right-0 z-[9998] bg-[#050B14] flex flex-col items-center justify-center overflow-hidden"
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
className="fixed left-0 w-full z-[9998] bg-[#050B14] pointer-events-none flex flex-col items-center justify-center overflow-visible"
style={{
height: '100vh',
top: '0',
@@ -29,7 +29,7 @@ export function PageTransitionShutter() {
{/* Deep Glow inside the Shutter */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent opacity-80 mix-blend-screen" />
<m.div
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.05 }}
@@ -54,10 +54,10 @@ export function PageTransitionShutter() {
<div className="absolute inset-0 bg-white/70" />
{/* Sweeping intense white light */}
<m.div
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
initial={{ x: '-150%' }}
animate={{ x: '250%' }}
initial={{ left: '-150%' }}
animate={{ left: '250%' }}
transition={{ duration: 1.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.2 }}
/>
</div>
@@ -69,7 +69,7 @@ export function PageTransitionShutter() {
(() => {
const [, fromLang, toLang] = transitionMessage.split(':');
return (
<m.div
<motion.div
key="lang-switch-graphic"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
@@ -78,7 +78,7 @@ export function PageTransitionShutter() {
>
{/* Old Language Flag - Fading out */}
<div className="w-20 h-20 sm:w-28 sm:h-28 rounded-[2rem] bg-white/5 border border-white/10 backdrop-blur-md flex items-center justify-center text-5xl sm:text-7xl relative overflow-hidden group grayscale opacity-50">
<m.div
<motion.div
className="absolute inset-0 bg-white/10"
animate={{ scale: [1, 2], opacity: [0.5, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
@@ -88,7 +88,7 @@ export function PageTransitionShutter() {
{/* Animated Arrow connecting them */}
<div className="flex flex-col items-center gap-3">
<m.div
<motion.div
animate={{ x: [0, 20, 0] }}
transition={{ duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
className="text-primary drop-shadow-[0_0_15px_rgba(var(--color-primary),0.8)]"
@@ -97,7 +97,7 @@ export function PageTransitionShutter() {
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</m.div>
</motion.div>
<div className="flex gap-2 opacity-50">
<span className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--color-primary),1)]" />
<span className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--color-primary),1)]" style={{ animationDelay: '150ms' }} />
@@ -108,17 +108,17 @@ export function PageTransitionShutter() {
{/* New Language Flag - Intense Glow */}
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-[2.5rem] bg-primary/10 border border-primary/50 shadow-[0_0_80px_rgba(var(--color-primary),0.5)] backdrop-blur-xl flex items-center justify-center text-6xl sm:text-8xl relative overflow-hidden ring-4 ring-primary/30">
<span className="relative z-10 drop-shadow-[0_0_15px_rgba(255,255,255,0.6)] leading-none">{toLang}</span>
<m.div
<motion.div
className="absolute inset-0 w-[200%] h-full bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg]"
animate={{ x: ['-100%', '100%'] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
/>
</div>
</m.div>
</motion.div>
);
})()
) : (
<m.div
<motion.div
key="text-message"
initial={{ opacity: 0, y: 15, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
@@ -126,13 +126,13 @@ export function PageTransitionShutter() {
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="mt-10 mb-2 px-6 py-2.5 rounded-full border border-primary/40 bg-primary/10 shadow-[0_0_40px_rgba(var(--color-primary),0.3)] backdrop-blur-xl relative overflow-hidden flex items-center justify-center"
>
<m.div
<motion.div
className="absolute inset-0 w-[200%] h-full bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg]"
animate={{ x: ['-100%', '100%'] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
/>
<span className="text-white font-heading tracking-[0.2em] uppercase text-xs sm:text-sm font-extrabold flex items-center gap-3 relative z-10 drop-shadow-[0_0_8px_rgba(255,255,255,0.5)]">
<m.svg
<motion.svg
animate={{ rotate: 360 }}
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-primary-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
@@ -140,15 +140,15 @@ export function PageTransitionShutter() {
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</m.svg>
</motion.svg>
{transitionMessage}
</span>
</m.div>
</motion.div>
)
)}
</AnimatePresence>
</m.div>
</m.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);

View File

@@ -2,16 +2,6 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { LazyMotion } from 'framer-motion';
import dynamic from 'next/dynamic';
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
// Defer loading the shutter to eliminate AnimatePresence from the initial bundle
const DynamicPageTransitionShutter = dynamic(
() => import('./PageTransitionShutter').then(mod => mod.PageTransitionShutter),
{ ssr: false }
);
interface TransitionContextType {
isTransitioning: boolean;
@@ -24,11 +14,9 @@ const TransitionContext = createContext<TransitionContextType | undefined>(undef
export function TransitionProvider({ children }: { children: ReactNode }) {
const [isTransitioning, setIsTransitioning] = useState(false);
const [transitionMessage, setTransitionMessage] = useState<string | null>(null);
const [hasMountedShutter, setHasMountedShutter] = useState(false);
const router = useRouter();
// We no longer eagerly load the shutter in the background.
// It will be loaded precisely when the user initiates a transition.
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);
@@ -36,6 +24,18 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
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;
@@ -55,7 +55,6 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
setTransitionMessage(message);
setIsTransitioning(true);
setHasMountedShutter(true);
// Die Animation des Shutters abwarten (ca. 700ms), dann pushen
setTimeout(() => {
@@ -76,38 +75,11 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
return (
<TransitionContext.Provider value={{ isTransitioning, transitionMessage, startTransition }}>
<React.Suspense fallback={null}>
<TransitionRouteWatcher
isTransitioningRef={isTransitioningRef}
setIsTransitioning={setIsTransitioning}
setTransitionMessage={setTransitionMessage}
/>
</React.Suspense>
<LazyMotion features={loadFeatures}>
{hasMountedShutter && <DynamicPageTransitionShutter />}
{children}
</LazyMotion>
{children}
</TransitionContext.Provider>
);
}
function TransitionRouteWatcher({ isTransitioningRef, setIsTransitioning, setTransitionMessage }: any) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (isTransitioningRef.current) {
const timer = setTimeout(() => {
setIsTransitioning(false);
setTimeout(() => setTransitionMessage(null), 300);
}, 100);
return () => clearTimeout(timer);
}
}, [pathname, searchParams, setIsTransitioning, setTransitionMessage, isTransitioningRef]);
return null;
}
export function useTransition() {
const context = useContext(TransitionContext);
if (!context) {

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useMotionValue, useTransform, animate, useInView, m } from 'framer-motion';
import { motion, useMotionValue, useTransform, animate, useInView } from 'framer-motion';
interface AnimatedCounterProps {
value: number;
@@ -41,8 +41,8 @@ export function AnimatedCounter({
}, [inView, value, duration, delay, count]);
return (
<m.span ref={ref} className={className}>
{prefix}<m.span>{rounded}</m.span>{suffix}
</m.span>
<motion.span ref={ref} className={className}>
{prefix}<motion.span>{rounded}</motion.span>{suffix}
</motion.span>
);
}

View File

@@ -37,7 +37,7 @@ export function TransitionLink({ children, href, onClick, transitionMessage, ...
};
return (
<Link href={href} onClick={handleClick} prefetch={props.prefetch ?? false} className={cn('cursor-pointer', props.className)} {...props}>
<Link href={href} onClick={handleClick} className={cn('cursor-pointer', props.className)} {...props}>
{children}
</Link>
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useInView, m } from 'framer-motion';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
/**
@@ -73,7 +73,7 @@ export function StratumLines({
{LINES.map((line, i) => {
const isFromRight = placement.includes('right') ? !line.fromRight : line.fromRight;
return (
<m.div
<motion.div
key={i}
style={{
width: `${line.width}%`,

View File

@@ -1,14 +0,0 @@
import os
from PIL import Image
def optimize_webp_aggressive(path, width):
if not os.path.exists(path):
return
img = Image.open(path)
wpercent = (width / float(img.size[0]))
hsize = int((float(img.size[1]) * float(wpercent)))
img = img.resize((width, hsize), Image.Resampling.LANCZOS)
img.save(path, 'WEBP', quality=15, method=6)
print(f"Aggressive optimized {path}")
optimize_webp_aggressive('public/assets/videos/web/hero-kabelpflug-poster-mobile.webp', 600)

View File

@@ -1,30 +0,0 @@
---
title: "Moderne Verfahren im Kabeltiefbau"
date: "2024-03-15T10:00:00.000Z"
excerpt: "Erfahren Sie mehr über die neuesten Technologien und Verfahren im modernen Kabelleitungstiefbau, von Horizontalspülbohrungen bis hin zu grabenlosen Verlegetechniken."
featuredImage: "/assets/images/blog/tiefbau.jpg"
category: "Technik"
public: true
---
# Moderne Verfahren im Kabeltiefbau
Der Kabelleitungstiefbau hat sich in den letzten Jahren rasant entwickelt. Neue Technologien ermöglichen eine effizientere und umweltschonendere Verlegung von Versorgungsleitungen.
## Grabenlose Verlegetechniken
Eines der wichtigsten Verfahren ist die **Horizontalspülbohrung (HDD)**. Dieses Verfahren ermöglicht es, Leitungen unter Hindernissen wie Straßen, Flüssen oder Gebäuden zu verlegen, ohne die Oberfläche aufbrechen zu müssen.
### Vorteile der HDD-Bohrung:
- Minimale Beeinträchtigung des Verkehrs
- Schutz der Umwelt und vorhandener Vegetation
- Kürzere Bauzeiten
- Geringere Kosten bei komplexen Unterquerungen
## Klassischer Tiefbau in neuer Qualität
Auch der klassische Grabenbau profitiert von modernen Maschinen und präziser Planung. Durch den Einsatz von Saugbaggern und GPS-gestützter Vermessung können wir Schäden an Bestandsleitungen nahezu ausschließen.
## Fazit
Die Kombination aus bewährter Handwerkskunst und modernster Technik ist der Schlüssel zu erfolgreichen Infrastrukturprojekten. Wir bei E-TIB setzen konsequent auf Innovation, um unseren Kunden die bestmöglichen Ergebnisse zu liefern.

View File

@@ -88,7 +88,7 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
{/* Right Column: Imagery & Mission */}
<div className="lg:col-span-7 relative">
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
<ResponsiveImage src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB im Einsatz" className="w-full h-full object-cover" loading="lazy" decoding="async" />
<img src="/_next/image?url=%2Fassets%2Fphotos%2FEtib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg&w=1920&q=75" alt="E-TIB im Einsatz" className="w-full h-full object-cover" loading="lazy" decoding="async" />
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/40 to-transparent"></div>
<div className="absolute bottom-0 left-0 w-full p-8 md:p-12">
@@ -118,7 +118,7 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
items={[
{
title: "Kabelleitungsnetzbau",
description: "Kabelleitungsnetzbau (Nieder-/Mittel- und Hochspannung bis 110 kV)",
description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
tag: "Energie",
size: "large",
href: "/de/kabeltiefbau",

View File

@@ -8,7 +8,7 @@ layout: "fullBleed"
<HeroSection
badge="Kernkompetenz"
title="Kabelleitungsnetzbau"
subtitle="Komplette Infrastruktur-Lösungen für Nieder-, Mittel- und Hochspannungsnetze bis 110 kV"
subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"
backgroundImage={{ url: '/assets/photos/DSC01123.JPG' }}
alignment="center"
ctaLabel="Projekt anfragen"

View File

@@ -1,30 +0,0 @@
---
title: "Modern Methods in Cable Civil Engineering"
date: "2024-03-15T10:00:00.000Z"
excerpt: "Learn more about the latest technologies and methods in modern cable civil engineering, from horizontal directional drilling to trenchless installation techniques."
featuredImage: "/assets/images/blog/tiefbau.jpg"
category: "Technology"
public: true
---
# Modern Methods in Cable Civil Engineering
Cable civil engineering has evolved rapidly in recent years. New technologies enable more efficient and environmentally friendly installation of utility lines.
## Trenchless Installation Techniques
One of the most important methods is **Horizontal Directional Drilling (HDD)**. This method allows pipes to be laid under obstacles such as roads, rivers, or buildings without having to break open the surface.
### Benefits of HDD Drilling:
- Minimal impact on traffic
- Protection of the environment and existing vegetation
- Shorter construction times
- Lower costs for complex crossings
## Classic Civil Engineering in New Quality
Classic trench construction also benefits from modern machinery and precise planning. By using suction excavators and GPS-supported surveying, we can virtually eliminate damage to existing utility lines.
## Conclusion
The combination of proven craftsmanship and state-of-the-art technology is the key to successful infrastructure projects. At E-TIB, we consistently focus on innovation to deliver the best possible results for our customers.

View File

@@ -88,7 +88,7 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
{/* Right Column: Imagery & Mission */}
<div className="lg:col-span-7 relative">
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
<ResponsiveImage src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB in action" className="w-full h-full object-cover" loading="lazy" decoding="async" />
<img src="/_next/image?url=%2Fassets%2Fphotos%2FEtib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg&w=1920&q=75" alt="E-TIB in action" className="w-full h-full object-cover" loading="lazy" decoding="async" />
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/40 to-transparent"></div>
<div className="absolute bottom-0 left-0 w-full p-8 md:p-12">

View File

@@ -1 +0,0 @@
export default {};

View File

@@ -1,5 +1,5 @@
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
// from being included in the initial JS bundle.
export {};
import * as Sentry from '@sentry/nextjs';
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More