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
255 changed files with 12689 additions and 57904 deletions

View File

@@ -1,9 +0,0 @@
<RULE[zero_trust_no_proof]>
# 🚫 ZERO TRUST & BEWEISPFLICHT
Dein Wort hat absolut keinen Wert, wenn du es nicht mit einem harten Test beweisen kannst.
- **Keine Behauptungen:** Wenn du sagst, dass etwas funktioniert, ist das wertlos. Code, der nicht getestet ist, gilt als defekt.
- **Beweispflicht:** Jede Code-Änderung, jeder Fix und jede Logik-Anpassung MUSS durch einen begleitenden, erfolgreichen automatisierten Test bewiesen werden.
- **TDD (Test-Driven Development) ist Gesetz:** Ohne einen fehlschlagenden Test darfst du keinen Code schreiben. Ohne einen grünen Test darfst du keinen Code als fertig deklarieren.
</RULE[zero_trust_no_proof]>

3
.env
View File

@@ -27,7 +27,7 @@ UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking (GlitchTip/Sentry) # Error Tracking (GlitchTip/Sentry)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Optional: Leave empty to disable error tracking # Optional: Leave empty to disable error tracking
SENTRY_DSN=https://dcb81958dbf24a3db422875f4672c14b@errors.infra.mintel.me/5 SENTRY_DSN=https://dcb81958-dbf2-4a3d-b422-875f4672c14b@glitchtip.infra.mintel.me/5
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Email Configuration (SMTP) # Email Configuration (SMTP)
@@ -45,6 +45,7 @@ MAIL_RECIPIENTS=info@e-tib.com
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
LOG_LEVEL=info LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026 GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN=
# SENTRY_ENVIRONMENT is set automatically by CI # SENTRY_ENVIRONMENT is set automatically by CI
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────

View File

@@ -45,6 +45,7 @@ MAIL_RECIPIENTS=info@e-tib.com
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
LOG_LEVEL=info LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026 GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN=
# SENTRY_ENVIRONMENT is set automatically by CI # SENTRY_ENVIRONMENT is set automatically by CI
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────

View File

@@ -16,7 +16,7 @@ UMAMI_WEBSITE_ID=d773ea10-a3b3-4ccf-9024-987e14c4d669
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking (GlitchTip/Sentry) # Error Tracking (GlitchTip/Sentry)
SENTRY_DSN=https://dcb81958dbf24a3db422875f4672c14b@errors.infra.mintel.me/5 SENTRY_DSN=
# Email Configuration (Mailgun) # Email Configuration (Mailgun)
MAIL_HOST=smtp.eu.mailgun.org MAIL_HOST=smtp.eu.mailgun.org

View File

@@ -20,7 +20,6 @@ concurrency:
env: env:
PUPPETEER_SKIP_DOWNLOAD: "true" PUPPETEER_SKIP_DOWNLOAD: "true"
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com" COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
GIT_HTTP_VERSION: HTTP/1.1
jobs: jobs:
@@ -47,12 +46,37 @@ jobs:
run: | run: |
echo "=== System Disk Usage ===" echo "=== System Disk Usage ==="
df -h || true df -h || true
echo "Purging old tool caches skipped to prevent runner corruption." echo "=== Tool Cache Usage ==="
du -sh /opt/hostedtoolcache/* || true
du -sh /opt/hostedtoolcache/.[!.]* || true
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 ==="
docker run --rm -v /:/host-root alpine df -i || true
echo "=== Running Host Containers ===" echo "=== Running Host Containers ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
echo "=== Host Docker Info ===" echo "=== Host Docker Info ==="
docker info || true 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 df -h || true
- name: Checkout repository - name: Checkout repository
@@ -88,15 +112,6 @@ jobs:
fi fi
else else
TARGET="branch" 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/-$//') SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}" IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}" ENV_FILE=".env.branch-${SLUG}"
@@ -203,13 +218,6 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 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 - name: 🔐 Registry Auth
id: auth id: auth
env: env:
@@ -289,13 +297,6 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 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 - name: 📝 Generate Environment
shell: bash shell: bash
env: env:
@@ -333,9 +334,6 @@ jobs:
{ {
echo "# Generated by CI - $TARGET" echo "# Generated by CI - $TARGET"
echo "IMAGE_TAG=$IMAGE_TAG" echo "IMAGE_TAG=$IMAGE_TAG"
if [[ "$TARGET" != "production" ]]; then
echo "NODE_TLS_REJECT_UNAUTHORIZED=0"
fi
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL" echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN" echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
echo "SENTRY_DSN=$SENTRY_DSN" echo "SENTRY_DSN=$SENTRY_DSN"
@@ -402,19 +400,10 @@ jobs:
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
ssh root@alpha.mintel.me "docker system prune -f" ssh root@alpha.mintel.me "docker system prune -f"
ssh root@alpha.mintel.me "docker rm -f \$(docker ps -a -q --filter name=${SLUG}-etib-) 2>/dev/null || true"
# ── 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 "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 && 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 '${{ needs.prepare.outputs.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' up -d --remove-orphans"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'" 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

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 # Excel generation moved to post-deploy
# Stage 2: Runner # Stage 2: Runner
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner FROM node:20-alpine AS runner
WORKDIR /app 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 HOSTNAME="0.0.0.0"
ENV PORT=3000 ENV PORT=3000
ENV NODE_ENV=production ENV NODE_ENV=production

View File

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

View File

@@ -1,6 +1,4 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { getImageProps } from 'next/image';
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
import { Container, Badge, Heading } from '@/components/ui'; import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
@@ -51,34 +49,10 @@ const mdxComponents = {
JsonLd, JsonLd,
Button, Button,
Badge, Badge,
Heading,
AnimatedCounter, AnimatedCounter,
GrowthChart, GrowthChart,
DeepDrillAnimation, DeepDrillAnimation,
DataGridPulse, DataGridPulse,
ScaleOfImpact,
ResponsiveImage: (props: any) => {
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) {
// Ignore invalid URL parsing
}
}
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" />
);
},
// Standard HTML element mapping for consistent E-TIB typography // Standard HTML element mapping for consistent E-TIB typography
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1 h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />, h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,

View File

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

View File

@@ -1,6 +1,4 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { getImageProps } from 'next/image';
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { import {
@@ -26,29 +24,6 @@ const mdxComponents = {
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />, h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />, h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />,
h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />, h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />,
ScaleOfImpact,
ResponsiveImage: (props: any) => {
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) {
// Ignore invalid URL parsing
}
}
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" />
);
},
p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-normal max-w-3xl" {...props} />, p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-normal max-w-3xl" {...props} />,
ul: (props: any) => <ul className="list-none mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />, ul: (props: any) => <ul className="list-none mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />, ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
@@ -143,7 +118,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
{/* Title overlay on image */} {/* Title overlay on image */}
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24"> <div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
<div className="container mx-auto max-w-7xl px-4"> <div className="container mx-auto px-4">
<div className="max-w-4xl bg-neutral-dark/40 backdrop-blur-md p-6 rounded-2xl border border-white/10"> <div className="max-w-4xl bg-neutral-dark/40 backdrop-blur-md p-6 rounded-2xl border border-white/10">
{post.frontmatter.category && ( {post.frontmatter.category && (
<div className="overflow-hidden mb-6"> <div className="overflow-hidden mb-6">
@@ -217,7 +192,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
)} )}
{/* Main Content Area with Sticky Narrative Layout */} {/* Main Content Area with Sticky Narrative Layout */}
<div className="container mx-auto max-w-7xl px-4 py-16 md:py-24"> <div className="container mx-auto px-4 py-16 md:py-24">
<div className="sticky-narrative-container"> <div className="sticky-narrative-container">
{/* Left Column: Content */} {/* Left Column: Content */}
<div className="sticky-narrative-content"> <div className="sticky-narrative-content">

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,10 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { getAppServices } from '@/lib/services/create-services';
import { Button, Heading } from '@/components/ui'; import { Button, Heading } from '@/components/ui';
import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react'; import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
export default function Error({ export default function Error({
error, error,
@@ -25,7 +26,9 @@ export default function Error({
return; 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', message: error?.message || 'Unknown error',
stack: error?.stack, stack: error?.stack,
digest: error?.digest, 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"> <div className="relative z-10 w-full max-w-4xl mx-auto flex flex-col items-center">
{/* The "Broken" Machine Visual */} {/* The "Broken" Machine Visual */}
<div className="relative mb-12"> <div className="relative mb-12">
<m.div <motion.div
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="relative" className="relative"
@@ -78,7 +81,7 @@ export default function Error({
{/* Warning Overlay */} {/* Warning Overlay */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center"> <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={{ animate={{
opacity: [0.4, 1, 0.4], opacity: [0.4, 1, 0.4],
scale: [0.95, 1.05, 0.95] 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)]" 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" /> <ShieldAlert className="w-12 h-12 text-red-500" />
</m.div> </motion.div>
</div> </div>
</m.div> </motion.div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 w-full items-start"> <div className="grid grid-cols-1 md:grid-cols-12 gap-8 w-full items-start">

View File

@@ -1,83 +1,13 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import https from 'https';
const ALLOWED_HOSTS = ['glitchtip.infra.mintel.me', 'errors.infra.mintel.me'];
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const rawText = await req.text(); const rawText = await req.text();
const items = rawText.split('\n'); // Sentry sends NDJSON (Newline Delimited JSON). Split by newline to parse safely.
const items = rawText.split('\n').filter(Boolean).map(line => JSON.parse(line));
if (items.length === 0 || !items[0]) { console.log("CLIENT ERROR INTERCEPTED:", JSON.stringify(items[0], null, 2));
return NextResponse.json({ error: 'Empty payload' }, { status: 400 });
}
const header = JSON.parse(items[0]);
// Use the server's configured DSN (if available) to override the client's potentially fake DSN
// This allows the client to work without exposing NEXT_PUBLIC_SENTRY_DSN
const dsn = process.env.SENTRY_DSN || header.dsn;
if (!dsn) {
return NextResponse.json({ error: 'No DSN found' }, { status: 400 });
}
const url = new URL(dsn);
const projectId = url.pathname.replace('/', '');
const host = url.hostname;
if (!ALLOWED_HOSTS.includes(host)) {
return NextResponse.json({ error: 'Invalid Sentry Host' }, { status: 400 });
}
// Rewrite the DSN in the envelope header so Glitchtip doesn't reject the fake client DSN
if (process.env.SENTRY_DSN) {
header.dsn = process.env.SENTRY_DSN;
items[0] = JSON.stringify(header);
}
const sentryIngestUrl = `https://${host}/api/${projectId}/envelope/`;
const payloadToForward = items.join('\n');
return new Promise<NextResponse>((resolve) => {
const req = https.request(
sentryIngestUrl,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
// Bypass self-signed cert error since glitchtip.infra.mintel.me uses an internal CA
// that the Next.js docker container doesn't trust by default.
rejectUnauthorized: false,
},
(res) => {
res.on('data', () => {
// Consume data to free up memory
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(NextResponse.json({ success: true }));
} else {
resolve(
NextResponse.json(
{ error: `Relay rejected (${res.statusCode})` },
{ status: res.statusCode || 500 }
)
);
}
});
}
);
req.on('error', (err) => {
console.error('[Sentry Tunnel] https.request failed', err);
resolve(NextResponse.json({ error: 'Relay failed' }, { status: 500 }));
});
req.write(payloadToForward);
req.end();
});
} catch (e) { } catch (e) {
console.error("[Sentry Tunnel] Failed to parse or relay body", e); console.log("Failed to parse relay body (NDJSON)", e);
return NextResponse.json({ error: 'Relay failed' }, { status: 500 });
} }
return NextResponse.json({ success: true });
} }

View File

@@ -1,5 +1,6 @@
import { Footer } from '@/components/layout/Footer'; import { Footer } from '@/components/layout/Footer';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { MobileBottomNav } from '@/components/layout/MobileBottomNav';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import SkipLink from '@/components/SkipLink'; import SkipLink from '@/components/SkipLink';
import AnalyticsShell from '@/components/analytics/AnalyticsShell'; import AnalyticsShell from '@/components/analytics/AnalyticsShell';
@@ -9,21 +10,16 @@ import { getMessages } from 'next-intl/server';
import '../../styles/globals.css'; import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper'; import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
import AnnotatorClientWrapper from '@/components/AnnotatorClientWrapper';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import { mapFileSlugToTranslated } from '@/lib/slugs'; import { mapFileSlugToTranslated } from '@/lib/slugs';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { TransitionProvider } from '@/components/providers/TransitionProvider'; import { TransitionProvider } from '@/components/providers/TransitionProvider';
import { PageTransitionShutter } from '@/components/providers/PageTransitionShutter';
import { DynamicInitialLoader as InitialLoader } from '@/components/providers/DynamicInitialLoader'; import { InitialLoader } from '@/components/providers/InitialLoader';
import { DynamicMobileBottomNav as MobileBottomNav } from '@/components/layout/DynamicMobileBottomNav'; import { CorporateBackground } from '@/components/decorations/CorporateBackground';
import { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground';
import { DynamicPageTransitionShutter as PageTransitionShutter } from '@/components/providers/DynamicPageTransitionShutter';
import { DynamicFramerMotion as FramerMotionProvider } from '@/components/providers/DynamicFramerMotion';
const inter = Inter({ const inter = Inter({
subsets: ['latin'], subsets: ['latin'],
display: 'swap', display: 'swap',
@@ -36,8 +32,7 @@ export async function generateMetadata(props: {
const params = await props.params; const params = await props.params;
const { locale } = params; const { locale } = params;
const baseUrl = SITE_URL; const baseUrl = process.env.CI ? 'http://etib.localhost' : SITE_URL;
return { return {
title: { title: {
template: '%s | E-TIB', template: '%s | E-TIB',
@@ -47,11 +42,10 @@ export async function generateMetadata(props: {
metadataBase: new URL(baseUrl), metadataBase: new URL(baseUrl),
manifest: '/manifest.webmanifest', manifest: '/manifest.webmanifest',
alternates: { alternates: {
canonical: `/${locale}`, canonical: `${baseUrl}/${locale}`,
languages: { languages: {
'de': `/de`, de: `${baseUrl}/de`,
'en': `/en`, en: `${baseUrl}/en`,
'x-default': `/de`,
}, },
}, },
}; };
@@ -66,9 +60,6 @@ export const viewport: Viewport = {
themeColor: '#117c61', themeColor: '#117c61',
}; };
export async function generateStaticParams() {
return [{ locale: 'de' }, { locale: 'en' }];
}
export default async function Layout(props: { export default async function Layout(props: {
children: React.ReactNode; children: React.ReactNode;
@@ -157,9 +148,19 @@ export default async function Layout(props: {
const serverServices = getServerAppServices(); const serverServices = getServerAppServices();
try { try {
const { headers } = await import('next/headers');
const requestHeaders = await headers();
// Disable analytics in CI to prevent console noise/score penalties // Disable analytics in CI to prevent console noise/score penalties
if (process.env.NEXT_PUBLIC_CI === 'true') { if (process.env.NEXT_PUBLIC_CI === 'true') {
// Skip setting server context for analytics in CI // 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. // Server-side analytics tracking removed to prevent duplicate/empty events.
@@ -174,24 +175,30 @@ export default async function Layout(props: {
// Read directly from process.env — bypasses all abstraction to guarantee correctness // Read directly from process.env — bypasses all abstraction to guarantee correctness
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true'; 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 ( return (
<html <html
lang={safeLocale} lang={safeLocale}
className={`scroll-smooth overflow-x-hidden ${inter.variable}`} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
data-scroll-behavior="smooth" data-scroll-behavior="smooth"
suppressHydrationWarning
> >
<head> <head>
<link rel="preconnect" href="https://img.infra.mintel.me" />
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-icon.png" sizes="180x180" /> <link rel="apple-touch-icon" href="/apple-icon.png" sizes="180x180" />
</head> </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}> <NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<TransitionProvider> <TransitionProvider>
<FramerMotionProvider>
<CorporateBackground /> <CorporateBackground />
<InitialLoader shouldShowLoader={!hasSeenLoader} /> <InitialLoader shouldShowLoader={!hasSeenLoader} />
<PageTransitionShutter /> <PageTransitionShutter />
@@ -201,7 +208,7 @@ export default async function Layout(props: {
<main <main
id="main-content" id="main-content"
className="flex-grow overflow-visible" className="flex-grow animate-fade-in overflow-visible"
tabIndex={-1} tabIndex={-1}
> >
{children} {children}
@@ -210,9 +217,9 @@ export default async function Layout(props: {
<Footer companyInfo={companyInfo} /> <Footer companyInfo={companyInfo} />
<JsonLd /> <JsonLd />
<AnalyticsShell /> <AnalyticsShell />
{/* Annotator vorübergehend deaktiviert auf Staging laut Anforderung */}
<FeedbackClientWrapper /> {/* process.env.TARGET !== 'production' && <AnnotatorClientWrapper /> */}
</FramerMotionProvider> {feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
</TransitionProvider> </TransitionProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>

View File

@@ -4,7 +4,7 @@ import { useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button, Heading } from '@/components/ui'; import { Button, Heading } from '@/components/ui';
import { Terminal, Search, Home, Mail, Activity, Cpu } from 'lucide-react'; 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'; import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
export default function NotFound() { 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"> <div className="relative z-10 w-full max-w-4xl mx-auto flex flex-col items-center">
{/* 404 Visual */} {/* 404 Visual */}
<div className="relative mb-12"> <div className="relative mb-12">
<m.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="relative" className="relative"
@@ -62,7 +62,7 @@ export default function NotFound() {
{/* Search Scan Overlay */} {/* 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"> <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={{ animate={{
scale: [1, 1.1, 1], scale: [1, 1.1, 1],
opacity: [0.5, 1, 0.5] 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)]" 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" /> <Search className="w-10 h-10 text-primary" />
</m.div> </motion.div>
</div> </div>
</m.div> </motion.div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 w-full items-start"> <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 { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate'; 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 size = OG_IMAGE_SIZE;
export const contentType = 'image/png'; export const contentType = 'image/png';
@@ -10,17 +10,13 @@ export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) { export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Index.meta' }); const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts(); const fonts = await getOgFonts();
const bg = getOgBackground();
const logo = getOgLogo();
return new ImageResponse( return new ImageResponse(
<OGImageTemplate <OGImageTemplate
title="Die Experten für Kabelnetzbau" title={t('title')}
label="Kabelnetzbau & Infrastruktur" description={t('description')}
image={bg} label="Reliable Energy Infrastructure"
logo={logo}
/>, />,
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,

View File

@@ -4,16 +4,19 @@ import { notFound } from 'next/navigation';
import { getMdxContent } from '@/lib/mdx'; import { getMdxContent } from '@/lib/mdx';
import { MDXRemote } from 'next-mdx-remote/rsc'; import { MDXRemote } from 'next-mdx-remote/rsc';
import nextDynamic from 'next/dynamic'; export function generateStaticParams() {
import { getImageProps } from 'next/image'; return [{ locale: 'de' }, { locale: 'en' }];
}
import nextDynamic from 'next/dynamic';
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo'; import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
const HomeSubCompanyTiles = nextDynamic(() => import('@/components/blocks/SubCompanyTiles').then(mod => mod.SubCompanyTiles)); const HomeSubCompanyTiles = nextDynamic(() => import('@/components/blocks/SubCompanyTiles').then(mod => mod.SubCompanyTiles));
const HomeCompetenceBentoGrid = nextDynamic(() => import('@/components/blocks/CompetenceBentoGrid').then(mod => mod.CompetenceBentoGrid)); const HomeCompetenceBentoGrid = nextDynamic(() => import('@/components/blocks/CompetenceBentoGrid').then(mod => mod.CompetenceBentoGrid));
const HomeReferencesSlider = nextDynamic(() => import('@/components/blocks/ReferencesSlider').then(mod => mod.ReferencesSlider)); const HomeReferencesSlider = nextDynamic(() => import('@/components/blocks/ReferencesSlider').then(mod => mod.ReferencesSlider));
const FaqBlock = nextDynamic(() => import('@/components/blocks/FaqBlock').then(mod => mod.FaqBlock)); const FaqBlock = nextDynamic(() => import('@/components/blocks/FaqBlock').then(mod => mod.FaqBlock));
const CertificatesBlock = nextDynamic(() => import('@/components/blocks/CertificatesBlock').then(mod => mod.CertificatesBlock)); 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 JsonLd from '@/components/JsonLd';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -37,76 +40,9 @@ const mdxComponents = (references: any[]) => ({
AnimatedCounter, AnimatedCounter,
InteractiveGermanyMap, InteractiveGermanyMap,
ScaleOfImpact, 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) => { img: (props: any) => {
// Parse src if it's already a next image proxy // We proxy it through Next.js image optimizer
let src = props.src; return <img {...props} src={`/_next/image?url=${encodeURIComponent(props.src)}&w=1920&q=75`} loading="lazy" decoding="async" />;
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 }}
/>
);
} }
}); });
@@ -122,9 +58,7 @@ export async function generateMetadata(props: any): Promise<Metadata> {
}; };
} }
import { preload } from 'react-dom'; export default async function Home(props: any) {
export default async function Home(props: { params: Promise<{ locale: string }> }) {
const { locale } = await props.params; const { locale } = await props.params;
if (locale !== 'de' && locale !== 'en') { if (locale !== 'de' && locale !== 'en') {
@@ -132,7 +66,6 @@ export default async function Home(props: { params: Promise<{ locale: string }>
} }
setRequestLocale(locale); setRequestLocale(locale);
preload('/germany-map.svg', { as: 'image', fetchPriority: 'high' });
const mdx = await getMdxContent(locale, 'home'); const mdx = await getMdxContent(locale, 'home');
const allReferences = await getAllReferences(locale); const allReferences = await getAllReferences(locale);

View File

@@ -1,6 +1,4 @@
import { Container, Heading, Badge } from '@/components/ui'; import { Container, Heading, Badge } from '@/components/ui';
import { getImageProps } from 'next/image';
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getAllReferences } from '@/lib/references'; import { getAllReferences } from '@/lib/references';
@@ -30,9 +28,9 @@ const CustomLi = ({ children, ...props }: any) => {
else if (lowerText.includes('vlf') || lowerText.includes('otdr')) Icon = Zap; else if (lowerText.includes('vlf') || lowerText.includes('otdr')) Icon = Zap;
return ( 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}> <li className="flex items-start gap-3 p-3 bg-neutral-50/50 border border-neutral-100 rounded-xl" {...props}>
<Icon className="w-5 h-5 text-primary shrink-0 mt-0.5" /> <Icon className="w-4 h-4 text-primary shrink-0 mt-[2px]" />
<span className="text-sm font-medium text-neutral-700 leading-snug [&>p]:mb-0">{children}</span> <span className="text-sm font-medium text-neutral-700 leading-tight">{children}</span>
</li> </li>
); );
}; };
@@ -41,30 +39,6 @@ const mdxComponents = {
ul: (props: any) => <ul className="grid grid-cols-1 gap-2 mb-6" {...props} />, ul: (props: any) => <ul className="grid grid-cols-1 gap-2 mb-6" {...props} />,
li: CustomLi, li: CustomLi,
p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />, p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />,
Heading,
ScaleOfImpact,
ResponsiveImage: (props: any) => {
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) {
// Ignore invalid URL parsing
}
}
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" />
);
},
}; };
interface PageProps { interface PageProps {

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 { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import TrackedLink from '@/components/analytics/TrackedLink'; import TrackedLink from '@/components/analytics/TrackedLink';
@@ -33,16 +33,12 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
}; };
} }
import { preload } from 'react-dom';
export default async function StandorteOverview(props: { params: Promise<{ locale: string }> }) { export default async function StandorteOverview(props: { params: Promise<{ locale: string }> }) {
const { locale } = await props.params; const { locale } = await props.params;
const safeLocale = locale === 'de' ? 'de' : 'en'; const safeLocale = locale === 'de' ? 'de' : 'en';
setRequestLocale(safeLocale); setRequestLocale(safeLocale);
const t = await getTranslations('StandardPage'); const t = await getTranslations('StandardPage');
preload('/germany-map.svg', { as: 'image', fetchPriority: 'high' });
return ( return (
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24"> <div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
{/* Map Hero Section */} {/* Map Hero Section */}
@@ -74,7 +70,6 @@ export default async function StandorteOverview(props: { params: Promise<{ local
alt={standort.name} alt={standort.name}
fill 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" 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" /> <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 */} {/* Content Section */}
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white"> <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"> <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} {standort.name}
</h2> </h3>
<ArrowUpRight className="w-5 h-5 text-neutral-400 group-hover:text-primary transition-colors shrink-0" /> <ArrowUpRight className="w-5 h-5 text-neutral-400 group-hover:text-primary transition-colors shrink-0" />
</div> </div>
@@ -103,7 +98,7 @@ export default async function StandorteOverview(props: { params: Promise<{ local
{/* Meta Data */} {/* Meta Data */}
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10"> <div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
<div className="min-w-0"> <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'} {safeLocale === 'de' ? 'Adresse' : 'Address'}
</span> </span>
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight"> <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="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="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"> <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> <p className="text-lg text-white/70 mb-8">{t('nextProjectDesc')}</p>
<TrackedLink <TrackedLink
href={`/${safeLocale}/${safeLocale === 'de' ? 'kontakt' : 'contact'}`} href={`/${safeLocale}/${safeLocale === 'de' ? 'kontakt' : 'contact'}`}

View File

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

View File

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

View File

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

View File

@@ -3,34 +3,6 @@
@config "../tailwind.config.cjs"; @config "../tailwind.config.cjs";
/* Tailwind v4 Container Polyfill */
@utility container {
width: 100%;
margin-inline: auto !important;
padding-inline: 1rem;
@media (min-width: 640px) {
max-width: 640px;
padding-inline: 1.5rem;
}
@media (min-width: 768px) {
max-width: 768px;
padding-inline: 2rem;
}
@media (min-width: 1024px) {
max-width: 1024px;
padding-inline: 2.5rem;
}
@media (min-width: 1280px) {
max-width: 1280px;
padding-inline: 3rem;
}
@media (min-width: 1536px) {
max-width: 1536px;
padding-inline: 4rem;
}
}
/* E-TIB Custom Utilities */ /* E-TIB Custom Utilities */
@layer utilities { @layer utilities {
.text-balance { .text-balance {
@@ -115,31 +87,3 @@
} }
} }
/* trigger rebuild */ /* trigger rebuild */
@keyframes hero-fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@utility animate-hero-fade-in-up {
animation: hero-fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes hero-zoom-out {
from {
transform: scale(1.05);
}
to {
transform: scale(1);
}
}
@utility animate-hero-zoom-out {
animation: hero-zoom-out 1.5s ease-out forwards;
}

View File

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

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

@@ -81,7 +81,7 @@ export default function ContactForm() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<Heading level={3} align="center" className="mb-4"> <Heading level={3} className="mb-4">
{t('form.successTitle') || 'Message Sent!'} {t('form.successTitle') || 'Message Sent!'}
</Heading> </Heading>
<p className="text-text-secondary text-lg mb-8"> <p className="text-text-secondary text-lg mb-8">
@@ -98,7 +98,7 @@ export default function ContactForm() {
if (status === 'error') { if (status === 'error') {
return ( return (
<Card <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" role="alert"
aria-live="assertive" aria-live="assertive"
> >
@@ -115,7 +115,7 @@ export default function ContactForm() {
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" /> <line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</div> </div>
<Heading level={3} align="center" className="mb-4 text-destructive font-black"> <Heading level={3} className="mb-4 text-destructive font-black">
{t('form.errorTitle') || 'Submission Failed!'} {t('form.errorTitle') || 'Submission Failed!'}
</Heading> </Heading>
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium"> <p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
@@ -134,7 +134,7 @@ export default function ContactForm() {
} }
return ( 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"> <Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
{t('form.title')} {t('form.title')}
</Heading> </Heading>

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { createPortal } from 'react-dom'; 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'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps { interface LightboxProps {

View File

@@ -5,7 +5,6 @@ interface OGImageTemplateProps {
description?: string; description?: string;
label?: string; label?: string;
image?: string; image?: string;
logo?: string;
mode?: 'dark' | 'light' | 'image'; mode?: 'dark' | 'light' | 'image';
} }
@@ -14,11 +13,11 @@ export function OGImageTemplate({
description, description,
label, label,
image, image,
logo,
mode = 'dark', mode = 'dark',
}: OGImageTemplateProps) { }: OGImageTemplateProps) {
const backgroundDark = '#1a1a1a'; const primaryBlue = '#001a4d';
const primaryGreen = '#0e7a5c'; const accentGreen = '#10a379';
const saturatedBlue = '#011dff';
const containerStyle: React.CSSProperties = { const containerStyle: React.CSSProperties = {
height: '100%', height: '100%',
@@ -27,7 +26,7 @@ export function OGImageTemplate({
flexDirection: 'column', flexDirection: 'column',
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: mode === 'light' ? '#ffffff' : backgroundDark, backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
padding: '80px', padding: '80px',
position: 'relative', position: 'relative',
fontFamily: 'Inter', fontFamily: 'Inter',
@@ -66,7 +65,7 @@ export function OGImageTemplate({
left: 0, left: 0,
right: 0, right: 0,
bottom: 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> </div>
@@ -76,40 +75,27 @@ export function OGImageTemplate({
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '-300px', top: '-150px',
right: '-200px', right: '-150px',
width: '800px', width: '600px',
height: '1200px', height: '600px',
backgroundColor: `${primaryGreen}1A`, borderRadius: '300px',
transform: 'rotate(25deg)', backgroundColor: `${accentGreen}15`,
display: 'flex', display: 'flex',
}} }}
/> />
{/* Main Content Wrapper */} <div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
<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 / Category */}
{label && ( {label && (
<div <div
style={{ style={{
fontSize: '24px', fontSize: '24px',
fontWeight: 700, fontWeight: 700,
color: primaryGreen, color: accentGreen,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.25em', letterSpacing: '0.3em',
marginBottom: '24px', marginBottom: '32px',
display: 'flex', display: 'flex',
}} }}
> >
@@ -120,12 +106,12 @@ export function OGImageTemplate({
{/* Title */} {/* Title */}
<div <div
style={{ style={{
fontSize: '72px', fontSize: title.length > 40 ? '64px' : '82px',
fontWeight: 700, fontWeight: 700,
color: 'white', color: 'white',
lineHeight: '1.1', lineHeight: '1.05',
maxWidth: '900px', maxWidth: '950px',
marginBottom: '24px', marginBottom: '40px',
display: 'flex', display: 'flex',
letterSpacing: '-0.02em', letterSpacing: '-0.02em',
}} }}
@@ -137,49 +123,38 @@ export function OGImageTemplate({
{description && ( {description && (
<div <div
style={{ style={{
fontSize: '36px', fontSize: '32px',
color: 'rgba(255,255,255,0.85)', color: 'rgba(255,255,255,0.7)',
maxWidth: '850px', maxWidth: '850px',
lineHeight: '1.4', lineHeight: '1.4',
display: '-webkit-box', display: 'flex',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
fontWeight: 400, fontWeight: 400,
}} }}
> >
{description} {description.length > 160 ? description.substring(0, 157) + '...' : description}
</div> </div>
)} )}
</div> </div>
{/* Bottom: Brand Footer */} {/* Brand Footer */}
<div <div
style={{ style={{
position: 'absolute',
bottom: '80px',
left: '80px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
}} }}
> >
{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 <div
style={{ style={{
width: '80px', width: '80px',
height: '6px', height: '6px',
backgroundColor: primaryGreen, backgroundColor: accentGreen,
borderRadius: '3px', borderRadius: '3px',
marginRight: '24px', marginRight: '24px',
}} }}
/> />
)}
{!logo && (
<div <div
style={{ style={{
fontSize: '24px', fontSize: '24px',
@@ -190,13 +165,11 @@ export function OGImageTemplate({
display: 'flex', display: 'flex',
}} }}
> >
E-TIB GmbH KLZ Cables
</div>
)}
</div> </div>
</div> </div>
{/* Primary Green Brand Strip */} {/* Saturated Blue Brand Strip */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -204,7 +177,7 @@ export function OGImageTemplate({
right: 0, right: 0,
width: '12px', width: '12px',
height: '100%', height: '100%',
backgroundColor: primaryGreen, backgroundColor: saturatedBlue,
}} }}
/> />
</div> </div>

View File

@@ -26,7 +26,7 @@ export default function ProductSidebar({
const t = useTranslations('Products'); const t = useTranslations('Products');
return ( 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 */} {/* 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-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"> <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') { if (status === 'success') {
return ( return (
<div <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" role="alert"
aria-live="polite" aria-live="polite"
> >
@@ -126,7 +126,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'error') { if (status === 'error') {
return ( return (
<div <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" role="alert"
aria-live="assertive" aria-live="assertive"
> >

View File

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

View File

@@ -22,18 +22,11 @@ export default function AnalyticsShell() {
return; return;
} }
const initServices = () => {
import('@/lib/services/create-services').then(({ getAppServices }) => {
getAppServices();
setShouldLoad(true);
}).catch(console.error);
};
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs // Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) { if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
window.requestIdleCallback(initServices, { timeout: 3000 }); window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
} else { } else {
const timer = setTimeout(initServices, 2500); const timer = setTimeout(() => setShouldLoad(true), 2500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, []); }, []);

View File

@@ -13,7 +13,13 @@ export default function ClientNotFoundTracker({ path }: { path: string }) {
path, 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]); }, [trackEvent, path]);
return null; return null;

View File

@@ -1,69 +1,73 @@
'use client'; 'use client';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { getAppServices } from '@/lib/services/create-services';
export type AnalyticsEventProperties = Record<string, any>; import type { AnalyticsEventProperties } from '@/lib/services/analytics/analytics-service';
/**
* 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);
}
}
/** /**
* Custom hook for tracking analytics events with Umami. * 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() { 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( const trackEvent = useCallback(
(eventName: string, properties?: AnalyticsEventProperties) => { (eventName: string, properties?: AnalyticsEventProperties) => {
sendAnalyticsPayload('event', { services.analytics.track(eventName, properties);
name: eventName,
data: properties,
url: window.location.pathname + window.location.search,
});
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked event:', eventName, properties); 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( const trackPageview = useCallback(
(url?: string) => { (url?: string) => {
sendAnalyticsPayload('event', { services.analytics.trackPageview(url);
url: url || window.location.pathname + window.location.search,
});
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked pageview:', url ?? 'current location'); console.log('[Umami] Tracked pageview:', url ?? 'current location');
} }
}, },
[] [services]
); );
return { return {

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
import { Container } from '@/components/ui/Container'; import { Container } from '@/components/ui/Container';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay'; import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
@@ -25,7 +25,7 @@ interface CertificatesBlockProps {
hideHeader?: boolean; hideHeader?: boolean;
} }
export const defaultCertificates: Certificate[] = [ const defaultCertificates: Certificate[] = [
{ {
title: 'ISO 14001:2015', title: 'ISO 14001:2015',
description: 'Umweltmanagementsystem', description: 'Umweltmanagementsystem',
@@ -47,13 +47,6 @@ export const defaultCertificates: Certificate[] = [
type: 'iso', type: 'iso',
date: '05.12.2025', date: '05.12.2025',
}, },
{
title: 'Betriebshaftpflichtversicherung',
description: 'RheinLand Versicherungen (10 Mio. € Deckung)',
pdfUrl: '/assets/certificates/betriebshaftpflicht.pdf',
type: 'general',
date: '25.03.2026',
},
{ {
title: 'Freistellungsbescheinigung', title: 'Freistellungsbescheinigung',
description: 'Nach § 48 b EStG', description: 'Nach § 48 b EStG',
@@ -75,6 +68,7 @@ export const defaultCertificates: Certificate[] = [
type: 'tax', type: 'tax',
date: '13.02.2025', date: '13.02.2025',
}, },
]; ];
export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) { export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) {
@@ -128,7 +122,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
<Container className="relative z-10"> <Container className="relative z-10">
{!hideHeader && ( {!hideHeader && (
<div className="text-center max-w-3xl mx-auto mb-16"> <div className="text-center max-w-3xl mx-auto mb-16">
<m.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }} viewport={{ once: true, margin: "-50px" }}
@@ -137,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> <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> <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> <p className="text-lg text-neutral-600">{descriptionText}</p>
</m.div> </motion.div>
</div> </div>
)} )}
<m.div <motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
@@ -150,12 +144,11 @@ export function CertificatesBlock({ badge, title, description, certificates = de
> >
{certificates.map((cert, index) => { {certificates.map((cert, index) => {
const isIso = cert.type === 'iso'; 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 ? { const wrapperProps = cert.pdfUrl ? {
href: encodeURI(cert.pdfUrl), href: encodeURI(cert.pdfUrl),
target: "_blank", target: "_blank",
rel: "noopener noreferrer", rel: "noopener noreferrer"
download: cert.pdfUrl.split('/').pop()
} : {}; } : {};
return ( return (
@@ -208,7 +201,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
</p> </p>
</div> </div>
<div className={`mt-auto inline-flex items-center gap-2 text-sm font-bold uppercase tracking-wider transition-all duration-300 ${ <div className={`mt-auto inline-flex items-center text-sm font-bold uppercase tracking-wider transition-all ${
cert.pdfUrl ? 'group-hover:gap-3' : '' cert.pdfUrl ? 'group-hover:gap-3' : ''
} ${ } ${
isIso ? 'text-primary-light' : 'text-primary' isIso ? 'text-primary-light' : 'text-primary'
@@ -216,7 +209,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
{cert.pdfUrl ? ( {cert.pdfUrl ? (
<> <>
<span>Download PDF</span> <span>Download PDF</span>
<Download size={16} className="group-hover:translate-y-0.5 transition-transform" /> <Download size={16} className="ml-2 group-hover:translate-y-0.5 transition-transform" />
</> </>
) : ( ) : (
<span className="text-neutral-400">Nachweis liegt vor</span> <span className="text-neutral-400">Nachweis liegt vor</span>
@@ -226,7 +219,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
</Wrapper> </Wrapper>
); );
})} })}
</m.div> </motion.div>
</Container> </Container>
</section> </section>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; 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 { Building2, Compass, Layers, ArrowDownToLine, Wrench, Factory, Zap, MapPin, CheckCircle2 } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
@@ -85,7 +85,7 @@ export function CompanyTimeline({
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" /> <div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" />
<div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" /> <div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" />
<div className="container mx-auto max-w-5xl relative z-10"> <div className="container max-w-5xl relative z-10">
<div className="text-center mb-12 md:mb-24"> <div className="text-center mb-12 md:mb-24">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-neutral-200 text-neutral-dark text-xs font-bold uppercase tracking-wider mb-6 shadow-sm"> <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-neutral-200 text-neutral-dark text-xs font-bold uppercase tracking-wider mb-6 shadow-sm">
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" /> <span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
@@ -102,7 +102,7 @@ export function CompanyTimeline({
{/* Animated Central Line */} {/* 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"> <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)]" className="w-full bg-primary drop-shadow-[0_0_8px_rgba(130,237,32,0.8)]"
style={{ height: lineHeight }} style={{ height: lineHeight }}
/> />
@@ -114,7 +114,7 @@ export function CompanyTimeline({
const Icon = iconMap[milestone.iconName] || Building2; const Icon = iconMap[milestone.iconName] || Building2;
return ( return (
<m.div <motion.div
key={i} key={i}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -159,7 +159,7 @@ export function CompanyTimeline({
</div> </div>
</div> </div>
</m.div> </motion.div>
); );
})} })}
</div> </div>

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay'; 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> <h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
</div> </div>
</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 */} {/* Simplified static grid */}
</div> </div>
</Container> </Container>
@@ -88,7 +88,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
</div> </div>
</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) => { {items.map((item, idx) => {
const isLarge = item.size === 'large'; const isLarge = item.size === 'large';
const isMedium = item.size === 'medium'; const isMedium = item.size === 'medium';
@@ -103,7 +103,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
if (isAccent) { if (isAccent) {
return ( return (
<m.div <motion.div
key={idx} key={idx}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} 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} /> <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> <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>} {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 ( return (
<m.div <motion.div
key={idx} key={idx}
initial={{ opacity: 0, y: 20, scale: 0.98 }} initial={{ opacity: 0, y: 20, scale: 0.98 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }} whileInView={{ opacity: 1, y: 0, scale: 1 }}
@@ -182,7 +182,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
</p> </p>
)} )}
</div> </div>
</m.div> </motion.div>
); );
})} })}
</div> </div>

View File

@@ -4,7 +4,7 @@ import React, { Suspense } from 'react';
import { Section, Container } from '@/components/ui'; import { Section, Container } from '@/components/ui';
import { ContactForm } from '@/components/forms/ContactForm'; import { ContactForm } from '@/components/forms/ContactForm';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
const ContactMap = dynamic(() => import('@/components/ContactMap'), { const ContactMap = dynamic(() => import('@/components/ContactMap'), {
ssr: false, ssr: false,
@@ -44,21 +44,21 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
<Section className="bg-neutral-light relative overflow-hidden"> <Section className="bg-neutral-light relative overflow-hidden">
<Container> <Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16"> <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" className="lg:col-span-5 flex flex-col justify-center"
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
whileInView="visible" whileInView="visible"
viewport={{ once: true, margin: "-50px" }} viewport={{ once: true, margin: "-50px" }}
> >
<m.h2 variants={itemVariants} className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Direktkontakt</m.h2> <motion.h2 variants={itemVariants} className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Direktkontakt</motion.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> <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>
<m.p variants={itemVariants} className="text-lg text-text-secondary mb-8 leading-relaxed"> <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. 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"> <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"> <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> <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> </div>
@@ -68,9 +68,9 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
<a href="tel:+4935616857733">+49 (0) 3561 / 68577 33</a> <a href="tel:+4935616857733">+49 (0) 3561 / 68577 33</a>
</p> </p>
</div> </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"> <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> <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> </div>
@@ -80,9 +80,9 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
<a href="mailto:info@e-tib.com">info@e-tib.com</a> <a href="mailto:info@e-tib.com">info@e-tib.com</a>
</p> </p>
</div> </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"> <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> <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> </div>
@@ -94,12 +94,12 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
Deutschland Deutschland
</p> </p>
</div> </div>
</m.div> </motion.div>
</div> </div>
</m.div> </motion.div>
{showForm && ( {showForm && (
<m.div <motion.div
className="lg:col-span-7 relative" className="lg:col-span-7 relative"
variants={formVariants} variants={formVariants}
initial="hidden" initial="hidden"
@@ -113,12 +113,12 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
> >
<ContactForm /> <ContactForm />
</Suspense> </Suspense>
</m.div> </motion.div>
)} )}
</div> </div>
</Container> </Container>
{showMap && ( {showMap && (
<m.section <motion.section
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }} whileInView={{ opacity: 1 }}
viewport={{ once: true }} viewport={{ once: true }}
@@ -135,7 +135,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
]} ]}
/> />
</Suspense> </Suspense>
</m.section> </motion.section>
)} )}
</Section> </Section>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
export function DataGridPulse() { export function DataGridPulse() {
@@ -19,12 +19,12 @@ export function DataGridPulse() {
/> />
{/* Pulsing horizontal lines (Data Flow) */} {/* Pulsing horizontal lines (Data Flow) */}
<m.div <motion.div
animate={{ x: ["-100%", "100%"] }} animate={{ x: ["-100%", "100%"] }}
transition={{ duration: 5, repeat: Infinity, ease: "linear" }} 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]" 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%"] }} animate={{ x: ["100%", "-100%"] }}
transition={{ duration: 7, repeat: Infinity, ease: "linear" }} 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]" 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'; 'use client';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useScroll, useTransform, m } from 'framer-motion'; import { motion, useScroll, useTransform } from 'framer-motion';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
export function DeepDrillAnimation() { export function DeepDrillAnimation() {
@@ -33,17 +33,17 @@ export function DeepDrillAnimation() {
{/* The Drill Path */} {/* The Drill Path */}
<div className="relative w-2 h-96 bg-white/10 rounded-full mb-16"> <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" 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)' }} style={{ height: drillDepth, boxShadow: '0 0 20px rgba(130,237,32,0.6)' }}
> >
{/* The Drill Head */} {/* 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)]" /> <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> </div>
{/* The Reveal Data */} {/* The Reveal Data */}
<m.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }} viewport={{ once: true, margin: "-50px" }}
@@ -56,7 +56,7 @@ export function DeepDrillAnimation() {
<div className="text-sm font-bold uppercase tracking-widest text-primary"> <div className="text-sm font-bold uppercase tracking-widest text-primary">
{locale === 'en' ? 'Drilled distance since 2023' : 'Gebohrte Strecke seit 2023'} {locale === 'en' ? 'Drilled distance since 2023' : 'Gebohrte Strecke seit 2023'}
</div> </div>
</m.div> </motion.div>
</div> </div>
</div> </div>

View File

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

View File

@@ -3,6 +3,7 @@
import React from 'react'; import React from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { motion } from 'framer-motion';
import { Badge, Container, Heading } from '@/components/ui'; import { Badge, Container, Heading } from '@/components/ui';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -16,7 +17,25 @@ export interface HeroSectionProps {
ctaHref?: string; 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) => { export const HeroSection: React.FC<HeroSectionProps> = (props) => {
const { title, badge, subtitle, backgroundImage, alignment, ctaLabel, ctaHref } = 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' : ''}`} 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 && ( {bgSrc && (
<div <motion.div
key={`hero-bg-${pathname}`} 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 <Image
src={bgSrc} src={bgSrc}
@@ -52,34 +74,37 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
{/* Top Fade for Header Navigation Readability */} {/* 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 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' : ''}`}> <Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
<div <motion.div
key={`hero-content-${pathname}`} key={`hero-content-${pathname}`}
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`} className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
variants={containerVariants}
initial="hidden"
animate="visible"
> >
{badge && ( {badge && (
<div className="animate-hero-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}> <motion.div variants={itemVariants}>
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg"> <Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
{badge} {badge}
</Badge> </Badge>
</div> </motion.div>
)} )}
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', animationFillMode: 'both' }}> <motion.div variants={itemVariants}>
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8"> <Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
{title} {title}
</Heading> </Heading>
</div> </motion.div>
{subtitle && ( {subtitle && (
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', animationFillMode: 'both' }}> <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' : ''}`}> <p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}>
{subtitle} {subtitle}
</p> </p>
</div> </motion.div>
)} )}
{ctaLabel && ctaHref && ( {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', animationFillMode: 'both' }}> <motion.div variants={itemVariants} className={`mt-8 ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`}>
<Button <Button
href={ctaHref} href={ctaHref}
variant="accent" variant="accent"
@@ -90,9 +115,9 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
&rarr; &rarr;
</span> </span>
</Button> </Button>
</div> </motion.div>
)} )}
</div> </motion.div>
</Container> </Container>
</section> </section>
); );

View File

@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button';
import Image from 'next/image'; import Image from 'next/image';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { preload } from 'react-dom';
interface HeroVideoProps { interface HeroVideoProps {
data?: any; data?: any;
@@ -22,8 +23,6 @@ interface HeroVideoProps {
badge?: string; badge?: string;
} }
import placeholders from '@/lib/placeholders.json';
export function HeroVideo(props: HeroVideoProps) { export function HeroVideo(props: HeroVideoProps) {
const { data } = props; const { data } = props;
const title = props.title || data?.title || 'DIE EXPERTEN FÜR KABELNETZBAU'; const title = props.title || data?.title || 'DIE EXPERTEN FÜR KABELNETZBAU';
@@ -32,12 +31,7 @@ export function HeroVideo(props: HeroVideoProps) {
const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4'); const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4');
const pathname = usePathname(); const pathname = usePathname();
let defaultPoster = "/assets/photos/DJI_0048.JPG"; const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || "/assets/photos/DJI_0048.JPG";
if (videoUrl && videoUrl.endsWith('.mp4')) {
defaultPoster = videoUrl.replace('.mp4', '-poster.jpg');
}
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || defaultPoster;
const posterAlt = props.backgroundImage?.alt || data?.posterImage?.alt || "E-TIB Gruppe Baustelle"; const posterAlt = props.backgroundImage?.alt || data?.posterImage?.alt || "E-TIB Gruppe Baustelle";
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken'; const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
@@ -48,6 +42,10 @@ export function HeroVideo(props: HeroVideoProps) {
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project'); const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`; const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`;
if (posterSrc) {
preload(posterSrc, { as: 'image', fetchPriority: 'high' });
}
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false); const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
useEffect(() => { useEffect(() => {
let interactionTriggered = false; let interactionTriggered = false;
@@ -57,19 +55,17 @@ export function HeroVideo(props: HeroVideoProps) {
interactionTriggered = true; interactionTriggered = true;
setVideoSrcLoaded(true); setVideoSrcLoaded(true);
// Cleanup listeners ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'].forEach((event) =>
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.removeEventListener(event, handleInteraction) window.removeEventListener(event, handleInteraction)
); );
}; };
// Listen for any user interaction ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'].forEach((event) =>
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.addEventListener(event, handleInteraction, { passive: true, once: true }) window.addEventListener(event, handleInteraction, { passive: true, once: true })
); );
return () => { return () => {
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) => ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'].forEach((event) =>
window.removeEventListener(event, handleInteraction) window.removeEventListener(event, handleInteraction)
); );
}; };
@@ -80,32 +76,30 @@ export function HeroVideo(props: HeroVideoProps) {
{/* Background color while video loads */} {/* Background color while video loads */}
<div className="absolute inset-0 z-0 bg-neutral-dark" /> <div className="absolute inset-0 z-0 bg-neutral-dark" />
{/* ALWAYS render the Next.js optimized Image as the LCP element */} {/* 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" />
<Image <Image
key={`img-${pathname}-${posterSrc}`}
src={posterSrc} src={posterSrc}
alt={posterAlt} alt={posterAlt}
fill fill
className="object-cover z-[1] pointer-events-none"
sizes="100vw"
quality={25}
priority priority
fetchPriority="high" fetchPriority="high"
className="object-cover"
/> />
{/* Fast compositing layer to replace the heavy CSS filters on the image */} {videoUrl && videoSrcLoaded && (
<div className="absolute inset-0 bg-black/20 z-[1] pointer-events-none mix-blend-multiply" />
{/* Render video on top if available, but defer src to avoid blocking LCP */}
{videoUrl && (
<video <video
key={`${pathname}-${videoUrl}`} 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'}`} className="absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90"
src={videoSrcLoaded ? videoUrl : undefined} src={videoUrl}
autoPlay autoPlay
muted muted
loop loop
playsInline playsInline
preload="none"
/> />
)} )}
@@ -119,10 +113,10 @@ export function HeroVideo(props: HeroVideoProps) {
{/* Top Fade for Header Navigation Readability */} {/* 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 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 className="container mx-auto max-w-7xl relative z-[4] text-center px-4"> <div className="container relative z-[4] text-center px-4">
<div <div
key={`hero-content-${pathname}`} key={`hero-content-${pathname}`}
className="relative" className="animate-in fade-in slide-in-from-bottom-10 duration-1000 ease-out fill-mode-both"
> >
<h1 <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" 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'; 'use client';
import React, { useState, useRef, useCallback, useMemo } from 'react'; 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 Image from 'next/image';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import dynamic from 'next/dynamic';
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder'; import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
import { Location, projectLocations } from '@/lib/map-data'; import { Location, projectLocations } from '@/lib/map-data';
import { standorteLocations } from '@/lib/standorte-data'; import { standorteLocations } from '@/lib/standorte-data';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
const InteractiveMapPins = dynamic(() => import('./InteractiveMapPins'), {
ssr: false,
});
interface Stat { interface Stat {
value: string; value: string;
suffix?: string; suffix?: string;
@@ -32,7 +28,58 @@ interface InteractiveGermanyMapProps {
hideStandorte?: boolean; 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({ export function InteractiveGermanyMap({
badge, badge,
@@ -88,12 +135,12 @@ export function InteractiveGermanyMap({
const mapBackground = useMemo(() => ( const mapBackground = useMemo(() => (
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu"> <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" src="/germany-map.svg"
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"} alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
className="w-full h-full object-cover" fill
fetchPriority="high" className="object-cover"
decoding="async" priority
/> />
</div> </div>
), [locale]); ), [locale]);
@@ -163,14 +210,116 @@ export function InteractiveGermanyMap({
{mapBackground} {mapBackground}
<InteractiveMapPins {finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
locations={finalLocations} <MinorNode
locale={locale} key={`minor-${loc.id || idx}`}
t={t} loc={loc}
activeLocation={activeLocation} isActive={activeLocation === loc}
handleMouseEnter={handleMouseEnter} onEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave} 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> </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 Link from 'next/link';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay'; import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export interface JobListingBlockProps { export interface JobListingBlockProps {
@@ -53,7 +53,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
showFairs, showFairs,
fairsTitle = t('fairsTitle'), fairsTitle = t('fairsTitle'),
fairs = [ fairs = [
{ name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Energie-Messe', location: 'Messe München' }, { name: 'Intersolar München', date: 'Termin folgt', type: 'Energie-Messe', location: 'Messe München' },
{ name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' }, { name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' },
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' } { name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
], ],
@@ -69,10 +69,10 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
return ( return (
<div className="py-12 md:py-24"> <div className="py-12 md:py-24">
<div className="container max-w-7xl mx-auto"> <div className="container mx-auto">
{showFairs && ( {showFairs && (
<div className="mb-12 md:mb-24 relative"> <div className="mb-12 md:mb-24 relative">
<m.h3 <motion.h3
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }} 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" /> <span className="w-12 h-1.5 bg-primary rounded-full" />
{fairsTitle} {fairsTitle}
</m.h3> </motion.h3>
<m.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
whileInView="visible" whileInView="visible"
@@ -159,25 +159,25 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
if (isLink) { if (isLink) {
return ( 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} {innerContent}
</m.a> </motion.a>
); );
} }
return ( return (
<m.div variants={itemVariants} key={`fair-${idx}`} className={baseClasses}> <motion.div variants={itemVariants} key={`fair-${idx}`} className={baseClasses}>
{innerContent} {innerContent}
</m.div> </motion.div>
); );
})} })}
</m.div> </motion.div>
</div> </div>
)} )}
{showJobs && ( {showJobs && (
<div className="mb-12"> <div className="mb-12">
<m.h2 <motion.h2
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }} 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" className="font-heading font-extrabold text-3xl md:text-4xl text-neutral-dark mb-6 md:mb-8"
> >
{title} {title}
</m.h2> </motion.h2>
{jobs.length > 0 ? ( {jobs.length > 0 ? (
<m.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
whileInView="visible" whileInView="visible"
@@ -196,7 +196,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
className="grid gap-6" className="grid gap-6"
> >
{jobs.map((job: any) => ( {jobs.map((job: any) => (
<m.div variants={itemVariants} key={job.id}> <motion.div variants={itemVariants} key={job.id}>
<Link <Link
href={`/karriere/${job.slug}`} 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" 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> <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> </div>
</Link> </Link>
</m.div> </motion.div>
))} ))}
</m.div> </motion.div>
) : ( ) : (
<m.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }} viewport={{ once: true, margin: "-50px" }}
@@ -229,7 +229,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
<Button href={emptyStateLinkHref} variant="primary" className="mt-6"> <Button href={emptyStateLinkHref} variant="primary" className="mt-6">
{emptyStateLinkText} {emptyStateLinkText}
</Button> </Button>
</m.div> </motion.div>
)} )}
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay'; import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
@@ -47,7 +47,6 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
const isDraggingRef = React.useRef(false); const isDraggingRef = React.useRef(false);
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false);
const badge = props.badge || data?.badge || t('badge'); const badge = props.badge || data?.badge || t('badge');
const title = props.title || data?.title || t('title'); const title = props.title || data?.title || t('title');
const description = props.description || data?.description || t('description'); const description = props.description || data?.description || t('description');
@@ -98,30 +97,25 @@ 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"> <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="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" />
<div className="container mx-auto max-w-7xl relative z-10 mb-12 flex flex-col items-center justify-center text-center gap-6"> <div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div> <div className="text-left">
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2> <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> <h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
</div> </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} {description}
</div> </div>
</div> </div>
<div className="relative z-10"> <div className="w-full relative z-10">
{/* Fade edges for peek effect */} <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 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 <div
ref={containerRef} ref={containerRef}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp} onMouseUp={onMouseUp}
onMouseMove={onMouseMove} 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'}`} 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) => { {references.map((ref, i) => {
const imgSrc = ref.image const imgSrc = ref.image
@@ -129,25 +123,21 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
: fallbacks[i % fallbacks.length]; : fallbacks[i % fallbacks.length];
return ( return (
<m.div <motion.div
key={ref.id} key={ref.id}
data-card
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }} viewport={{ once: true, margin: "-50px" }}
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }} 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" className="flex-shrink-0 w-[320px] md:w-[480px] snap-start group pointer-events-auto"
> >
<Link <Link
href={`/${locale}/referenzen#${ref.slug}`} href={`/${locale}/referenzen#${ref.slug}`}
onClick={(e) => { onClick={(e) => {
if (dragDistanceRef.current > 5) { if (dragDistanceRef.current > 5) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
} }
}} }}
onDragStart={(e) => e.preventDefault()}
draggable={false}
data-testid="reference-tile" 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" 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"
> >
@@ -156,7 +146,6 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
src={imgSrc} src={imgSrc}
alt={ref.title} alt={ref.title}
fill fill
quality={50}
sizes="(max-width: 768px) 320px, 480px" 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" 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"
/> />
@@ -171,15 +160,13 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4> <h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
</div> </div>
</Link> </Link>
</m.div> </motion.div>
); );
})} })}
</div>
</div> </div>
</div> <div className="container relative z-10 mt-4 flex justify-center">
<div className="container mx-auto max-w-7xl relative z-10 mt-4 flex justify-center">
<Link <Link
href={ctaHref} href={ctaHref}
className="px-8 py-4 rounded-xl bg-white/5 border border-white/20 hover:bg-white hover:text-neutral-dark hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] transition-all font-bold flex items-center gap-3 backdrop-blur-sm" className="px-8 py-4 rounded-xl bg-white/5 border border-white/20 hover:bg-white hover:text-neutral-dark hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] transition-all font-bold flex items-center gap-3 backdrop-blur-sm"

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useScroll, useTransform, m } from 'framer-motion'; import { motion, useScroll, useTransform } from 'framer-motion';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
export function ScaleOfImpact() { 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" 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 */} {/* Massive Watermark Numbers */}
<m.div <motion.div
style={{ opacity: bgOpacity }} style={{ opacity: bgOpacity }}
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none overflow-hidden mix-blend-overlay" 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"> <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> <span className="font-mono tracking-tighter">1.435.223</span>
</div> </div>
</m.div> </motion.div>
{/* Foreground Content */} {/* Foreground Content */}
<div className="relative z-10 container max-w-7xl mx-auto px-4 flex flex-col items-center text-center"> <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" strokeWidth="2"
/> />
{/* Glowing animated line */} {/* Glowing animated line */}
<m.path <motion.path
d="M0,50 Q250,0 500,50 T1000,50" d="M0,50 Q250,0 500,50 T1000,50"
fill="none" fill="none"
stroke="#ffc107" // warning/yellow color stroke="#ffc107" // warning/yellow color
@@ -84,19 +84,19 @@ export function ScaleOfImpact() {
{/* Static Data Grid (No jumping text) */} {/* 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="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="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 className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
</div> </div>
<div className="flex flex-col items-center"> <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 className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
</div> </div>
<div className="flex flex-col items-center"> <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 className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Open Trenching' : 'Offener Tiefbau'}</div>
</div> </div>
<div className="flex flex-col items-center"> <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 className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m, Variants } from 'framer-motion'; import { motion, Variants } from 'framer-motion';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay'; import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
export interface ServicePanelData { export interface ServicePanelData {
@@ -133,7 +133,7 @@ export function ServiceDetailGrid({
</div> </div>
{(problemStatement || solutionStatement) && ( {(problemStatement || solutionStatement) && (
<m.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }} viewport={{ once: true, margin: "-50px" }}
@@ -165,10 +165,10 @@ export function ServiceDetailGrid({
<p className="text-text-secondary leading-relaxed">{solutionStatement.text}</p> <p className="text-text-secondary leading-relaxed">{solutionStatement.text}</p>
</div> </div>
)} )}
</m.div> </motion.div>
)} )}
<m.div <motion.div
className="grid grid-cols-1 md:grid-cols-2 gap-8" className="grid grid-cols-1 md:grid-cols-2 gap-8"
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
@@ -180,7 +180,7 @@ export function ServiceDetailGrid({
const number = String(idx + 1).padStart(2, '0'); const number = String(idx + 1).padStart(2, '0');
return ( return (
<m.div <motion.div
key={panel.id} key={panel.id}
variants={itemVariants} 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' : ''}`} 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> </ul>
))} ))}
</div> </div>
</m.div> </motion.div>
); );
})} })}
</m.div> </motion.div>
</div> </div>
</section> </section>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m, Variants } from 'framer-motion'; import { motion, Variants } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay'; import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
@@ -69,18 +69,18 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
return ( return (
<section id="unternehmen" className="pt-12 pb-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden"> <section id="unternehmen" className="pt-12 pb-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
<div className="container mx-auto max-w-7xl relative z-10 px-4"> <div className="container relative z-10 px-4">
{(badge || title) && ( {(badge || title) && (
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16"> <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>} {title && <h3 className="font-heading text-3xl md:text-5xl font-extrabold text-white">{title}</h3>}
</div> </div>
)} )}
<div className="relative -mx-4 md:mx-0"> <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" /> <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" 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} variants={containerVariants}
initial="hidden" initial="hidden"
@@ -128,7 +128,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
? 'opacity-30 grayscale-0 scale-105' ? 'opacity-30 grayscale-0 scale-105'
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110' : '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> </div>
@@ -235,7 +235,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
}`; }`;
return ( return (
<m.div <motion.div
key={index} key={index}
variants={itemVariants} variants={itemVariants}
className="h-full flex-shrink-0 snap-center w-[85%] sm:w-[70%] md:w-full" 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} {CardContent}
</div> </div>
)} )}
</m.div> </motion.div>
); );
})} })}
</m.div> </motion.div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -3,7 +3,7 @@
import React from 'react'; import React from 'react';
import TrackedLink from '@/components/analytics/TrackedLink'; import TrackedLink from '@/components/analytics/TrackedLink';
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button'; import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
export interface SupportCTAProps { export interface SupportCTAProps {
title: string; title: string;
@@ -39,7 +39,7 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
const { title, description, buttonLabel, buttonHref } = props; const { title, description, buttonLabel, buttonHref } = props;
return ( return (
<m.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
whileInView="visible" 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="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"> <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> <motion.h3 variants={itemVariants} className="text-2xl md:text-3xl font-bold mb-4">{title}</motion.h3>
<m.p variants={itemVariants} className="text-lg text-white/70 mb-8">{description}</m.p> <motion.p variants={itemVariants} className="text-lg text-white/70 mb-8">{description}</motion.p>
<m.div variants={itemVariants}> <motion.div variants={itemVariants}>
<TrackedLink <TrackedLink
href={buttonHref} href={buttonHref}
className={getButtonClasses('accent', 'lg')} className={getButtonClasses('accent', 'lg')}
@@ -66,8 +66,8 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
</span> </span>
<ButtonOverlay variant="accent" /> <ButtonOverlay variant="accent" />
</TrackedLink> </TrackedLink>
</m.div> </motion.div>
</div> </div>
</m.div> </motion.div>
); );
}; };

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
@@ -29,7 +29,7 @@ export function TeamGrid({ members }: TeamGridProps) {
return ( return (
<section className="py-16 md:py-24 lg:py-32 bg-white relative overflow-hidden"> <section className="py-16 md:py-24 lg:py-32 bg-white relative overflow-hidden">
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl relative z-10"> <div className="container relative z-10">
<div className="mb-12"> <div className="mb-12">
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2> <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
<h3 className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-4">{t('title')}</h3> <h3 className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-4">{t('title')}</h3>
@@ -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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 xl:gap-10">
{members.map((member, i) => ( {members.map((member, i) => (
<m.div <motion.div
key={member.id} key={member.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -104,7 +104,7 @@ export function TeamGrid({ members }: TeamGridProps) {
)} )}
</div> </div>
</div> </div>
</m.div> </motion.div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useRef, useEffect } from 'react'; 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'; import { LogoArcs } from '@/components/ui/LogoArcs';
function MagneticRing({ function MagneticRing({
@@ -25,24 +25,17 @@ function MagneticRing({
// Only run on client with mouse // Only run on client with mouse
if (window.matchMedia("(pointer: coarse)").matches) return; if (window.matchMedia("(pointer: coarse)").matches) return;
let ticking = false;
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!ref.current) return; if (!ref.current) return;
if (!ticking) {
window.requestAnimationFrame(() => {
if (!ref.current) {
ticking = false;
return;
}
const rect = ref.current.getBoundingClientRect(); const rect = ref.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2; const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2; const centerY = rect.top + rect.height / 2;
const distX = e.clientX - centerX; const distX = e.clientX - centerX;
const distY = e.clientY - centerY; const distY = e.clientY - centerY;
const distance = Math.sqrt(distX * Math.pow(distX, 2) + Math.pow(distY, 2)); // Fix: correct pythagoras
// Calculate actual distance correctly
const actualDistance = Math.sqrt(distX * distX + distY * distY); const actualDistance = Math.sqrt(distX * distX + distY * distY);
if (actualDistance < pullRadius) { if (actualDistance < pullRadius) {
@@ -57,24 +50,20 @@ function MagneticRing({
x.set(0); x.set(0);
y.set(0); y.set(0);
} }
ticking = false;
});
ticking = true;
}
}; };
window.addEventListener('mousemove', handleMouseMove, { passive: true }); window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove); return () => window.removeEventListener('mousemove', handleMouseMove);
}, [x, y, pullRadius, pullStrength]); }, [x, y, pullRadius, pullStrength]);
return ( return (
<m.div <motion.div
ref={ref} ref={ref}
style={{ x, y }} style={{ x, y }}
className={className} className={className}
> >
{children} {children}
</m.div> </motion.div>
); );
} }
@@ -91,7 +80,7 @@ export function CorporateBackground() {
return ( return (
<div className="absolute inset-0 z-[50] overflow-hidden pointer-events-none hidden md:block" aria-hidden="true"> <div className="absolute inset-0 z-[50] overflow-hidden pointer-events-none hidden md:block" aria-hidden="true">
{/* 1. Top Right - Medium, rotating moderately */} {/* 1. Top Right - Medium, rotating moderately */}
<m.div <motion.div
style={{ style={{
y: y1, y: y1,
animationDelay: '0s', 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" 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"> <MagneticRing pullRadius={700} pullStrength={40} className="w-full h-full">
<m.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 40, repeat: Infinity, ease: "linear" }} transition={{ duration: 40, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-primary" className="w-full h-full text-primary"
> >
<LogoArcs className="w-full h-full" /> <LogoArcs className="w-full h-full" />
</m.div> </motion.div>
</MagneticRing> </MagneticRing>
</m.div> </motion.div>
{/* 2. Quarter way down left - Smaller, rotating fast reverse */} {/* 2. Quarter way down left - Smaller, rotating fast reverse */}
<m.div <motion.div
style={{ style={{
y: y2, y: y2,
animationDelay: '3s', 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" 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"> <MagneticRing pullRadius={500} pullStrength={50} className="w-full h-full">
<m.div <motion.div
animate={{ rotate: -360 }} animate={{ rotate: -360 }}
transition={{ duration: 30, repeat: Infinity, ease: "linear" }} transition={{ duration: 30, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-secondary" className="w-full h-full text-secondary"
> >
<LogoArcs className="w-full h-full -rotate-45" /> <LogoArcs className="w-full h-full -rotate-45" />
</m.div> </motion.div>
</MagneticRing> </MagneticRing>
</m.div> </motion.div>
{/* 3. Halfway down right - Medium, rotating slow */} {/* 3. Halfway down right - Medium, rotating slow */}
<m.div <motion.div
style={{ style={{
y: y3, y: y3,
animationDelay: '6s', 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" 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"> <MagneticRing pullRadius={600} pullStrength={35} className="w-full h-full">
<m.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 60, repeat: Infinity, ease: "linear" }} transition={{ duration: 60, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-primary" className="w-full h-full text-primary"
> >
<LogoArcs className="w-full h-full rotate-90" /> <LogoArcs className="w-full h-full rotate-90" />
</m.div> </motion.div>
</MagneticRing> </MagneticRing>
</m.div> </motion.div>
{/* 4. Bottom left - Larger, rotating moderately */} {/* 4. Bottom left - Larger, rotating moderately */}
<m.div <motion.div
style={{ style={{
y: y4, y: y4,
animationDelay: '9s', 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" 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"> <MagneticRing pullRadius={800} pullStrength={30} className="w-full h-full">
<m.div <motion.div
animate={{ rotate: -360 }} animate={{ rotate: -360 }}
transition={{ duration: 50, repeat: Infinity, ease: "linear" }} transition={{ duration: 50, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-secondary" className="w-full h-full text-secondary"
> >
<LogoArcs className="w-full h-full rotate-180" /> <LogoArcs className="w-full h-full rotate-180" />
</m.div> </motion.div>
</MagneticRing> </MagneticRing>
</m.div> </motion.div>
{/* 5. Deep bottom right - Smallest, very fast */} {/* 5. Deep bottom right - Smallest, very fast */}
<m.div <motion.div
style={{ style={{
y: y5, y: y5,
animationDelay: '12s', 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" 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"> <MagneticRing pullRadius={400} pullStrength={60} className="w-full h-full">
<m.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 25, repeat: Infinity, ease: "linear" }} transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
className="w-full h-full text-primary" className="w-full h-full text-primary"
> >
<LogoArcs className="w-full h-full -rotate-12" /> <LogoArcs className="w-full h-full -rotate-12" />
</m.div> </motion.div>
</MagneticRing> </MagneticRing>
</m.div> </motion.div>
</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'; 'use client';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
export function EUFundingBadge() { export function EUFundingBadge() {
return ( return (
<m.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }}
@@ -36,6 +36,6 @@ export function EUFundingBadge() {
Europäischen Union Europäischen Union
</span> </span>
</div> </div>
</m.div> </motion.div>
); );
} }

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m } from 'framer-motion'; import { motion } from 'framer-motion';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { sendContactFormAction } from '@/app/actions/contact'; import { sendContactFormAction } from '@/app/actions/contact';
@@ -37,7 +37,7 @@ export function ContactForm() {
</div> </div>
{status === 'success' ? ( {status === 'success' ? (
<m.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="bg-primary/10 border border-primary text-primary p-6 rounded-xl flex items-start gap-4" 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> <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> <p>Vielen Dank für Ihr Interesse an der E-TIB Gruppe. Ein Ansprechpartner wird sich zeitnah mit Ihnen in Verbindung setzen.</p>
</div> </div>
</m.div> </motion.div>
) : status === 'error' ? ( ) : status === 'error' ? (
<m.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} 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" 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> <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> <Button variant="outline" size="sm" className="mt-4" onClick={() => setStatus('idle')}>Erneut versuchen</Button>
</div> </div>
</m.div> </motion.div>
) : null} ) : null}
{status !== 'success' && status !== 'error' && ( {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" src="/uploads/2024/12/1694273920124-copy-2.webp"
alt={data?.subtitle || t('subtitle')} alt={data?.subtitle || t('subtitle')}
fill fill
className="object-cover object-center scale-105 " className="object-cover object-center scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100} quality={100}
/> />
@@ -34,7 +34,7 @@ export default function Experience({ data }: { data?: any }) {
</div> </div>
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12"> <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"> <dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{data?.badge1 || t('certifiedQuality')} {data?.badge1 || t('certifiedQuality')}
</dt> </dt>
@@ -42,7 +42,7 @@ export default function Experience({ data }: { data?: any }) {
{data?.badge1Text || t('vdeApproved')} {data?.badge1Text || t('vdeApproved')}
</dd> </dd>
</div> </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"> <dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{data?.badge2 || t('fullSpectrum')} {data?.badge2 || t('fullSpectrum')}
</dt> </dt>

View File

@@ -105,7 +105,7 @@ export default function Hero({ data }: { data?: any }) {
) )
.replace( .replace(
/<\/green>/g, /<\/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} {chunks}
</span> </span>
<div <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' }} style={{ animationDelay: '500ms' }}
> >
<Scribble variant="circle" /> <Scribble variant="circle" />
@@ -199,12 +199,12 @@ export default function Hero({ data }: { data?: any }) {
</div> </div>
</Container> </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 /> <HeroIllustration />
</div> </div>
<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' }} style={{ animationDelay: '2000ms' }}
> >
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1"> <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" src="/uploads/2024/12/DSC08036-Large.webp"
alt={data?.subtitle || t('subtitle')} alt={data?.subtitle || t('subtitle')}
fill fill
className="object-cover scale-105 " className="object-cover scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100} quality={100}
/> />
@@ -24,7 +24,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
</div> </div>
<Container className="relative z-10"> <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"> <Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8">
<span className="text-white">{data?.title || t('title')}</span> <span className="text-white">{data?.title || t('title')}</span>
</Heading> </Heading>

View File

@@ -37,7 +37,7 @@ export default function VideoSection({ data }: { data?: any }) {
</video> </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="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]"> <h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{data?.title ? ( {data?.title ? (
<span <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

@@ -29,7 +29,7 @@ export function Footer({ companyInfo }: FooterProps) {
<EUFundingBadge /> <EUFundingBadge />
<div className="container mx-auto max-w-7xl relative z-10 grid grid-cols-1 md:grid-cols-4 lg:grid-cols-12 gap-4 md:gap-6 lg:gap-8 mb-4 md:mb-12"> <div className="container relative z-10 grid grid-cols-1 md:grid-cols-4 lg:grid-cols-12 gap-4 md:gap-6 lg:gap-8 mb-4 md:mb-12">
{/* Brand Column Bento */} {/* Brand Column Bento */}
<div className="col-span-1 md:col-span-4 lg:col-span-5 flex flex-col justify-between bg-white/[0.02] border border-white/5 rounded-3xl p-5 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden"> <div className="col-span-1 md:col-span-4 lg:col-span-5 flex flex-col justify-between bg-white/[0.02] border border-white/5 rounded-3xl p-5 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" /> <div className="absolute inset-0 bg-gradient-to-br from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
@@ -217,7 +217,7 @@ export function Footer({ companyInfo }: FooterProps) {
</div> </div>
</div> </div>
<div className="container mx-auto max-w-7xl relative z-10"> <div className="container relative z-10">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 md:gap-6 py-4 px-5 md:py-6 md:px-8 rounded-2xl bg-white/[0.02] border border-white/5 backdrop-blur-md text-center md:text-left"> <div className="flex flex-col md:flex-row justify-between items-center gap-4 md:gap-6 py-4 px-5 md:py-6 md:px-8 rounded-2xl bg-white/[0.02] border border-white/5 backdrop-blur-md text-center md:text-left">
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-6 h-full"> <div className="flex flex-col md:flex-row items-center gap-4 md:gap-6 h-full">
<p className="text-neutral-400 text-xs font-medium m-0">&copy; {new Date().getFullYear()} E-TIB GmbH. {locale === 'de' ? 'Alle Rechte vorbehalten.' : 'All rights reserved.'}</p> <p className="text-neutral-400 text-xs font-medium m-0">&copy; {new Date().getFullYear()} E-TIB GmbH. {locale === 'de' ? 'Alle Rechte vorbehalten.' : 'All rights reserved.'}</p>

View File

@@ -77,11 +77,10 @@ describe('Header Dropdown Navigation TDD', () => {
rerender(<Header navLinks={mockNavLinks} />); 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(() => { await waitFor(() => {
const dropdown = screen.getByText('Kabelbau').closest('div[style*="perspective"]'); expect(screen.queryByText('Kabelbau')).toBeNull();
expect(dropdown?.className).toContain('invisible'); expect(screen.queryByText('Bohrtechnik')).toBeNull();
expect(dropdown?.className).toContain('opacity-0');
}); });
}); });
}); });

View File

@@ -4,6 +4,7 @@ import * as React from 'react';
import { TransitionLink } from '@/components/ui/TransitionLink'; import { TransitionLink } from '@/components/ui/TransitionLink';
import Image from 'next/image'; import Image from 'next/image';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { motion, useScroll, useTransform, AnimatePresence } from 'framer-motion';
import { LanguageSwitcher } from './LanguageSwitcher'; import { LanguageSwitcher } from './LanguageSwitcher';
export interface NavLink { export interface NavLink {
@@ -24,30 +25,21 @@ export function Header({ navLinks }: HeaderProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
const pathname = usePathname() || '/'; 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 // Determine current locale from pathname
const currentLocale = pathname.startsWith('/en') ? 'en' : 'de'; const currentLocale = pathname.startsWith('/en') ? 'en' : 'de';
React.useEffect(() => { React.useEffect(() => {
let animationFrameId: number;
const handleScroll = () => { const handleScroll = () => {
setIsScrolled(window.scrollY > 20); 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); window.addEventListener('scroll', handleScroll);
@@ -96,8 +88,8 @@ export function Header({ navLinks }: HeaderProps) {
> >
{/* Scroll bound shine effect */} {/* Scroll bound shine effect */}
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden rounded-full"> <div className="absolute inset-0 z-0 pointer-events-none overflow-hidden rounded-full">
<div <motion.div
ref={shineRef} 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'}`} 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> </div>
@@ -111,7 +103,6 @@ export function Header({ navLinks }: HeaderProps) {
priority priority
fetchPriority="high" fetchPriority="high"
sizes="192px" sizes="192px"
unoptimized
/> />
{/* Masked sweep overlay on hover */} {/* Masked sweep overlay on hover */}
<div <div
@@ -265,10 +256,12 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
</svg> </svg>
)} )}
{/* Active Indicator */} {/* Framer Motion Active Indicator */}
{isActive && !link.children && ( {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'}`} 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'}`} /> <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> </TransitionLink>
<> <AnimatePresence>
{link.children && ( {isHovered && link.children && (
<div <motion.div
className={`absolute top-full left-1/2 -translate-x-1/2 pt-6 w-64 z-50 transition-all duration-300 origin-top ${ initial={{ opacity: 0, y: 10, scale: 0.95, rotateX: -10 }}
isHovered ? 'opacity-100 scale-100 translate-y-0 visible' : 'opacity-0 scale-95 translate-y-2 invisible' animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
}`} exit={{ opacity: 0, y: 10, scale: 0.95, rotateX: -10 }}
style={{ perspective: 1000 }} 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`}> <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 */} {/* Top glowing accent line */}
@@ -326,9 +321,9 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
); );
})} })}
</div> </div>
</div> </motion.div>
)} )}
</> </AnimatePresence>
</div> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import * as React from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { TransitionLink } from '@/components/ui/TransitionLink'; import { TransitionLink } from '@/components/ui/TransitionLink';
import { Home, Layers, Star, Mail, Menu } from 'lucide-react'; import { Home, Layers, Star, Mail, Menu } from 'lucide-react';
import { m, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
export interface NavLink { export interface NavLink {
label: string; label: string;
@@ -94,8 +94,8 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
return ( 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"> <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]"> <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) => { {items.map((item) => {
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/) const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
? `/${currentLocale}${item.url}` ? `/${currentLocale}${item.url}`
@@ -113,7 +113,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
const innerContent = ( const innerContent = (
<> <>
{isActive && ( {isActive && (
<m.div <motion.div
layoutId="mobile-nav-active-bg" layoutId="mobile-nav-active-bg"
className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10" className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10"
transition={{ type: 'spring', stiffness: 400, damping: 25 }} 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"> <div className="relative flex flex-col items-center justify-center z-10 w-full h-full">
<m.div <motion.div
whileTap={{ scale: 0.8 }} whileTap={{ scale: 0.8 }}
animate={{ y: isActive ? -2 : 0 }} animate={{ y: isActive ? -2 : 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }} transition={{ type: 'spring', stiffness: 400, damping: 17 }}
> >
{item.icon} {item.icon}
</m.div> </motion.div>
<m.span <motion.span
animate={{ y: isActive ? 0 : 2 }} animate={{ opacity: isActive ? 1 : 0.7, y: isActive ? 0 : 2 }}
className="text-[10px] font-bold mt-1 tracking-wide" className="text-[9px] font-bold mt-1 tracking-wide"
> >
{item.label} {item.label}
</m.span> </motion.span>
</div> </div>
</> </>
); );
@@ -144,7 +144,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
key={item.label} key={item.label}
onClick={() => setIsFlyoutOpen(!isFlyoutOpen)} 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 ${ 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' }} style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
> >
@@ -159,7 +159,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
href={mappedUrl} href={mappedUrl}
onClick={() => setIsFlyoutOpen(false)} onClick={() => setIsFlyoutOpen(false)}
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none ${ 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' }} style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
> >
@@ -173,7 +173,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
<AnimatePresence> <AnimatePresence>
{isFlyoutOpen && ( {isFlyoutOpen && (
<> <>
<m.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} 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" className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[9997] md:hidden"
onClick={() => setIsFlyoutOpen(false)} onClick={() => setIsFlyoutOpen(false)}
/> />
<m.div <motion.div
initial={{ y: '100%', opacity: 0 }} initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }} exit={{ y: '100%', opacity: 0 }}
@@ -194,7 +194,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
setIsFlyoutOpen(false); 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" /> <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> <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> </div>
</m.div> </motion.div>
</> </>
)} )}
</AnimatePresence> </AnimatePresence>

View File

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

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,11 +0,0 @@
'use client';
import { LazyMotion } from 'framer-motion';
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
export function FramerMotionProvider({ children }: { children: React.ReactNode }) {
return (
<LazyMotion features={loadFeatures} strict>
{children}
</LazyMotion>
);
}

View File

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

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { m, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useTransition } from './TransitionProvider'; import { useTransition } from './TransitionProvider';
import Image from 'next/image'; import Image from 'next/image';
@@ -11,7 +11,7 @@ export function PageTransitionShutter() {
return ( return (
<AnimatePresence> <AnimatePresence>
{isTransitioning && ( {isTransitioning && (
<m.div <motion.div
key="shutter" key="shutter"
initial={{ y: '-100%' }} initial={{ y: '-100%' }}
animate={{ y: '0%' }} animate={{ y: '0%' }}
@@ -29,7 +29,7 @@ export function PageTransitionShutter() {
{/* Deep Glow inside the Shutter */} {/* 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" /> <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 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.05 }} exit={{ opacity: 0, scale: 1.05 }}
@@ -54,7 +54,7 @@ export function PageTransitionShutter() {
<div className="absolute inset-0 bg-white/70" /> <div className="absolute inset-0 bg-white/70" />
{/* Sweeping intense white light */} {/* 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]" className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
initial={{ left: '-150%' }} initial={{ left: '-150%' }}
animate={{ left: '250%' }} animate={{ left: '250%' }}
@@ -69,7 +69,7 @@ export function PageTransitionShutter() {
(() => { (() => {
const [, fromLang, toLang] = transitionMessage.split(':'); const [, fromLang, toLang] = transitionMessage.split(':');
return ( return (
<m.div <motion.div
key="lang-switch-graphic" key="lang-switch-graphic"
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
@@ -78,7 +78,7 @@ export function PageTransitionShutter() {
> >
{/* Old Language Flag - Fading out */} {/* 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"> <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" className="absolute inset-0 bg-white/10"
animate={{ scale: [1, 2], opacity: [0.5, 0] }} animate={{ scale: [1, 2], opacity: [0.5, 0] }}
transition={{ duration: 1.5, repeat: Infinity }} transition={{ duration: 1.5, repeat: Infinity }}
@@ -88,7 +88,7 @@ export function PageTransitionShutter() {
{/* Animated Arrow connecting them */} {/* Animated Arrow connecting them */}
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<m.div <motion.div
animate={{ x: [0, 20, 0] }} animate={{ x: [0, 20, 0] }}
transition={{ duration: 1.2, repeat: Infinity, ease: "easeInOut" }} transition={{ duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
className="text-primary drop-shadow-[0_0_15px_rgba(var(--color-primary),0.8)]" 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> <line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline> <polyline points="12 5 19 12 12 19"></polyline>
</svg> </svg>
</m.div> </motion.div>
<div className="flex gap-2 opacity-50"> <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)]" />
<span className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--color-primary),1)]" style={{ animationDelay: '150ms' }} /> <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 */} {/* 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"> <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> <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]" 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%'] }} animate={{ x: ['-100%', '100%'] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }} transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
/> />
</div> </div>
</m.div> </motion.div>
); );
})() })()
) : ( ) : (
<m.div <motion.div
key="text-message" key="text-message"
initial={{ opacity: 0, y: 15, scale: 0.9 }} initial={{ opacity: 0, y: 15, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
@@ -126,13 +126,13 @@ export function PageTransitionShutter() {
transition={{ type: "spring", stiffness: 300, damping: 25 }} 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" 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]" 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%'] }} animate={{ x: ['-100%', '100%'] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }} 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)]"> <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 }} animate={{ rotate: 360 }}
transition={{ duration: 8, repeat: Infinity, ease: "linear" }} 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" 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> <circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line> <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> <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} {transitionMessage}
</span> </span>
</m.div> </motion.div>
) )
)} )}
</AnimatePresence> </AnimatePresence>
</m.div> </motion.div>
</m.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
); );

View File

@@ -15,6 +15,8 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const [transitionMessage, setTransitionMessage] = useState<string | null>(null); const [transitionMessage, setTransitionMessage] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// We use a ref to track transition state without triggering the route change effect when it turns true // We use a ref to track transition state without triggering the route change effect when it turns true
const isTransitioningRef = React.useRef(isTransitioning); const isTransitioningRef = React.useRef(isTransitioning);
@@ -22,6 +24,18 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
isTransitioningRef.current = isTransitioning; isTransitioningRef.current = isTransitioning;
}, [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) => { const startTransition = useCallback((href: string, message: string | null = null) => {
// Wenn wir bereits navigieren, nichts tun // Wenn wir bereits navigieren, nichts tun
if (isTransitioning) return; if (isTransitioning) return;
@@ -61,35 +75,11 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
return ( return (
<TransitionContext.Provider value={{ isTransitioning, transitionMessage, startTransition }}> <TransitionContext.Provider value={{ isTransitioning, transitionMessage, startTransition }}>
<React.Suspense fallback={null}>
<TransitionRouteWatcher
isTransitioningRef={isTransitioningRef}
setIsTransitioning={setIsTransitioning}
setTransitionMessage={setTransitionMessage}
/>
</React.Suspense>
{children} {children}
</TransitionContext.Provider> </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() { export function useTransition() {
const context = useContext(TransitionContext); const context = useContext(TransitionContext);
if (!context) { if (!context) {

View File

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

View File

@@ -37,7 +37,7 @@ export function TransitionLink({ children, href, onClick, transitionMessage, ...
}; };
return ( 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} {children}
</Link> </Link>
); );

View File

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

View File

@@ -88,7 +88,7 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
{/* Right Column: Imagery & Mission */} {/* Right Column: Imagery & Mission */}
<div className="lg:col-span-7 relative"> <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"> <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 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"> <div className="absolute bottom-0 left-0 w-full p-8 md:p-12">

View File

@@ -20,9 +20,9 @@ layout: "fullBleed"
showFairs={true} showFairs={true}
showJobs={false} showJobs={false}
fairs={[ fairs={[
{ name: 'Intersolar München', date: '23. - 25. Juni 2026', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' },
{ name: 'Windenergietage Linstow', date: '10. - 12. November 2026', type: 'Fachkongress', location: 'Linstow', url: 'https://www.windenergietage.de/' }, { name: 'Windenergietage Linstow', date: '10. - 12. November 2026', type: 'Fachkongress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
{ name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }, { name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }
{ name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' }
]} ]}
/> />
</div> </div>

View File

@@ -23,14 +23,6 @@ layout: "fullBleed"
phone: "+49 1520 7230518", phone: "+49 1520 7230518",
image: "/assets/photos/team/danny.jpg" image: "/assets/photos/team/danny.jpg"
}, },
{
id: "kathrin-lemanski",
name: "Kathrin Lemanski",
position: "Kaufmännische Leitung",
email: "k.lemanski@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kathrin.jpg"
},
{ {
id: "dirk-schmidt", id: "dirk-schmidt",
name: "Dirk Schmidt", name: "Dirk Schmidt",
@@ -55,6 +47,22 @@ layout: "fullBleed"
phone: "+49 171 3868167", phone: "+49 171 3868167",
image: "/assets/photos/team/martin.jpg" image: "/assets/photos/team/martin.jpg"
}, },
{
id: "kerstin-joseph",
name: "Kerstin Joseph",
position: "Sekretariat / Buchhaltung",
email: "k.joseph@e-tib.com",
phone: "+49 3561 6851692",
image: "/assets/photos/team/kerstin.jpg"
},
{
id: "kathrin-lemanski",
name: "Kathrin Lemanski",
position: "Sachbearbeitung",
email: "k.lemanski@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kathrin.jpg"
},
{ {
id: "sven-kirbs", id: "sven-kirbs",
name: "Sven Kirbs", name: "Sven Kirbs",
@@ -63,21 +71,6 @@ layout: "fullBleed"
phone: "+49 151 29603652", phone: "+49 151 29603652",
image: "/assets/photos/team/sven.jpg" image: "/assets/photos/team/sven.jpg"
}, },
{
id: "kerstin-joseph",
name: "Kerstin Joseph",
position: "Buchhaltung / Controlling",
email: "k.joseph@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kerstin.jpg"
},
{
id: "katrin-haigold",
name: "Katrin Haigold",
position: "Buchhaltung / Personalwesen",
email: "k.haigold@e-tib.com",
phone: "+49 3561 6851692"
},
{ {
id: "maik-weichert", id: "maik-weichert",
name: "Maik Weichert", name: "Maik Weichert",

View File

@@ -88,7 +88,7 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
{/* Right Column: Imagery & Mission */} {/* Right Column: Imagery & Mission */}
<div className="lg:col-span-7 relative"> <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"> <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 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"> <div className="absolute bottom-0 left-0 w-full p-8 md:p-12">

View File

@@ -20,9 +20,9 @@ layout: "fullBleed"
showFairs={true} showFairs={true}
showJobs={false} showJobs={false}
fairs={[ fairs={[
{ name: 'Intersolar Munich', date: 'June 2325, 2026', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' },
{ name: 'Wind Energy Days Linstow', date: 'November 1012, 2026', type: 'Specialist Congress', location: 'Linstow', url: 'https://www.windenergietage.de/' }, { name: 'Wind Energy Days Linstow', date: 'November 1012, 2026', type: 'Specialist Congress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
{ name: 'Werkstatt Kabel 2026', date: 'November 2425, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }, { name: 'Werkstatt Kabel 2026', date: 'November 2425, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }
{ name: 'Intersolar Munich', date: 'June 810, 2027', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' }
]} ]}
/> />
</div> </div>

View File

@@ -23,14 +23,6 @@ layout: "fullBleed"
phone: "+49 1520 7230518", phone: "+49 1520 7230518",
image: "/assets/photos/team/danny.jpg" image: "/assets/photos/team/danny.jpg"
}, },
{
id: "kathrin-lemanski",
name: "Kathrin Lemanski",
position: "Commercial Management",
email: "k.lemanski@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kathrin.jpg"
},
{ {
id: "dirk-schmidt", id: "dirk-schmidt",
name: "Dirk Schmidt", name: "Dirk Schmidt",
@@ -55,6 +47,22 @@ layout: "fullBleed"
phone: "+49 171 3868167", phone: "+49 171 3868167",
image: "/assets/photos/team/martin.jpg" image: "/assets/photos/team/martin.jpg"
}, },
{
id: "kerstin-joseph",
name: "Kerstin Joseph",
position: "Secretariat / Accounting",
email: "k.joseph@e-tib.com",
phone: "+49 3561 6851692",
image: "/assets/photos/team/kerstin.jpg"
},
{
id: "kathrin-lemanski",
name: "Kathrin Lemanski",
position: "Administration",
email: "k.lemanski@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kathrin.jpg"
},
{ {
id: "sven-kirbs", id: "sven-kirbs",
name: "Sven Kirbs", name: "Sven Kirbs",
@@ -63,21 +71,6 @@ layout: "fullBleed"
phone: "+49 151 29603652", phone: "+49 151 29603652",
image: "/assets/photos/team/sven.jpg" image: "/assets/photos/team/sven.jpg"
}, },
{
id: "kerstin-joseph",
name: "Kerstin Joseph",
position: "Accounting / Controlling",
email: "k.joseph@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kerstin.jpg"
},
{
id: "katrin-haigold",
name: "Katrin Haigold",
position: "Accounting / Human Resources",
email: "k.haigold@e-tib.com",
phone: "+49 3561 6851692"
},
{ {
id: "maik-weichert", id: "maik-weichert",
name: "Maik Weichert", name: "Maik Weichert",

View File

@@ -39,13 +39,6 @@ layout: "fullBleed"
type: 'iso', type: 'iso',
date: '05.12.2025', date: '05.12.2025',
}, },
{
title: 'Commercial Liability Insurance',
description: 'RheinLand Insurance (€10M coverage)',
pdfUrl: '/assets/certificates/betriebshaftpflicht.pdf',
type: 'general',
date: '25.03.2026',
},
{ {
title: 'Exemption Certificate', title: 'Exemption Certificate',
description: 'According to § 48 b EStG', description: 'According to § 48 b EStG',

View File

@@ -27,7 +27,7 @@ services:
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}" - "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}"
# Public Router paths that bypass Gatekeeper auth (health, SEO, static assets, OG images) # Public Router paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|assets|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"

View File

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

View File

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

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