Merge hotfix/2.2.95-fixes into main, replacing all content
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m16s
Build & Deploy / 🏗️ Build (push) Successful in 2m14s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 50s
Build & Deploy / 🔔 Notify (push) Successful in 4s

This commit is contained in:
2026-07-03 15:41:41 +02:00
84 changed files with 36401 additions and 49135 deletions

View File

@@ -20,6 +20,7 @@ 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:
@@ -46,37 +47,12 @@ jobs:
run: | run: |
echo "=== System Disk Usage ===" echo "=== System Disk Usage ==="
df -h || true df -h || true
echo "=== Tool Cache Usage ===" echo "Purging old tool caches skipped to prevent runner corruption."
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 "Pruning host Buildkit builder caches aggressively..." echo "=== System Disk Usage After Check ==="
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
@@ -112,6 +88,15 @@ 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}"
@@ -218,6 +203,13 @@ 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:
@@ -297,6 +289,13 @@ 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:

View File

@@ -1 +0,0 @@
{"chromeFlags":"--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --headless=new","port":59457}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,9 @@
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 { motion } from 'framer-motion'; import { m } from 'framer-motion';
export default function Error({ export default function Error({
error, error,
@@ -26,9 +25,7 @@ export default function Error({
return; return;
} }
const services = getAppServices(); console.error('Application error caught by boundary', {
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,
@@ -65,7 +62,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">
<motion.div <m.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"
@@ -81,7 +78,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">
<motion.div <m.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]
@@ -90,9 +87,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" />
</motion.div> </m.div>
</div> </div>
</motion.div> </m.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,6 +1,5 @@
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';
@@ -17,9 +16,14 @@ 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 { InitialLoader } from '@/components/providers/InitialLoader'; import { DynamicInitialLoader as InitialLoader } from '@/components/providers/DynamicInitialLoader';
import { CorporateBackground } from '@/components/decorations/CorporateBackground'; import { DynamicMobileBottomNav as MobileBottomNav } from '@/components/layout/DynamicMobileBottomNav';
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',
@@ -32,7 +36,8 @@ export async function generateMetadata(props: {
const params = await props.params; const params = await props.params;
const { locale } = params; const { locale } = params;
const baseUrl = process.env.CI ? 'http://etib.localhost' : SITE_URL; const baseUrl = SITE_URL;
return { return {
title: { title: {
template: '%s | E-TIB', template: '%s | E-TIB',
@@ -42,11 +47,11 @@ export async function generateMetadata(props: {
metadataBase: new URL(baseUrl), metadataBase: new URL(baseUrl),
manifest: '/manifest.webmanifest', manifest: '/manifest.webmanifest',
alternates: { alternates: {
canonical: `${baseUrl}/${locale}`, canonical: `/${locale}`,
languages: { languages: {
'de': `${baseUrl}/de`, 'de': `/de`,
'en': `${baseUrl}/en`, 'en': `/en`,
'x-default': `${baseUrl}/de`, 'x-default': `/de`,
}, },
}, },
}; };
@@ -186,26 +191,28 @@ export default async function Layout(props: {
<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" suppressHydrationWarning>
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}> <NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<TransitionProvider> <TransitionProvider>
<CorporateBackground /> <FramerMotionProvider>
<InitialLoader shouldShowLoader={!hasSeenLoader} /> <CorporateBackground />
<PageTransitionShutter /> <InitialLoader shouldShowLoader={!hasSeenLoader} />
<SkipLink /> <PageTransitionShutter />
<Header navLinks={navLinks} /> <SkipLink />
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} /> <Header navLinks={navLinks} />
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
<main
id="main-content" <main
className="flex-grow animate-fade-in overflow-visible" id="main-content"
tabIndex={-1} className="flex-grow overflow-visible"
> tabIndex={-1}
{children} >
</main> {children}
</main>
<Footer companyInfo={companyInfo} /> <Footer companyInfo={companyInfo} />
<JsonLd /> <JsonLd />
<AnalyticsShell /> <AnalyticsShell />
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />} <FeedbackClientWrapper />
</FramerMotionProvider>
</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 { motion } from 'framer-motion'; import { m } 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">
<motion.div <m.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">
<motion.div <m.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" />
</motion.div> </m.div>
</div> </div>
</motion.div> </m.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

@@ -122,7 +122,9 @@ export async function generateMetadata(props: any): Promise<Metadata> {
}; };
} }
export default async function Home(props: any) { import { preload } from 'react-dom';
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') {
@@ -130,6 +132,7 @@ export default async function Home(props: any) {
} }
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,4 +1,4 @@
import { Container } from '@/components/ui'; import { Container } from '@/components/ui/Container';
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,11 +33,15 @@ 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">
@@ -70,6 +74,7 @@ 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" />
@@ -85,9 +90,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">
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark transition-colors leading-[1.2] group-hover:text-primary"> <h2 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}
</h3> </h2>
<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>
@@ -98,7 +103,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-400 font-bold mb-1"> <span className="block text-[10px] uppercase tracking-widest text-neutral-500 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">
@@ -117,7 +122,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">
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3> <h2 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h2>
<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 { motion } from 'framer-motion'; import { m } from 'framer-motion';
export default function Template({ children }: { children: React.ReactNode }) { export default function Template({ children }: { children: React.ReactNode }) {
return ( return (
<motion.div <m.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}
</motion.div> </m.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: `📩 ${notificationSubject}`, title: `📩 [E-TIB] ${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: '🚨 Contact Form Error', title: '🚨 [E-TIB] 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,6 +1,5 @@
'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';
@@ -10,7 +9,7 @@ export default function GlobalError({
error: Error & { digest?: string }; error: Error & { digest?: string };
}) { }) {
useEffect(() => { useEffect(() => {
Sentry.captureException(error); console.error('Global Error:', error);
}, [error]); }, [error]);
return ( return (

View File

@@ -87,3 +87,31 @@
} }
} }
/* 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;
}

48
bundle-stats.json Normal file
View File

@@ -0,0 +1,48 @@
.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

@@ -2,17 +2,17 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
const FeedbackOverlay = dynamic( const IS_ENABLED = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
);
interface FeedbackClientWrapperProps { const FeedbackOverlay = IS_ENABLED
feedbackEnabled: boolean; ? dynamic(
} () => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
)
: () => null;
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) { export default function FeedbackClientWrapper() {
if (!feedbackEnabled) return null; if (!IS_ENABLED) 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, LazyMotion, AnimatePresence } from 'framer-motion'; import { m, AnimatePresence, LazyMotion } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps { interface LightboxProps {

View File

@@ -2,34 +2,28 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from 'next/navigation';
import { getAppServices } from '@/lib/services/create-services'; import { useAnalytics } from './useAnalytics';
/** /**
* 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
services.analytics.trackPageview(url); 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

@@ -13,13 +13,7 @@ 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,73 +1,69 @@
'use client'; 'use client';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { getAppServices } from '@/lib/services/create-services';
import type { AnalyticsEventProperties } from '@/lib/services/analytics/analytics-service'; export type AnalyticsEventProperties = Record<string, any>;
/**
* Sends a payload to the proxy API without loading backend config/logger dependencies.
*/
function sendAnalyticsPayload(type: 'event', data: Record<string, any>) {
if (typeof window === 'undefined') return;
if (process.env.NEXT_PUBLIC_CI === 'true') return;
const payload = {
hostname: window.location.hostname,
screen: `${window.screen.width}x${window.screen.height}`,
language: navigator.language,
referrer: document.referrer,
title: document.title,
...data,
};
try {
fetch('/stats/api/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ type, payload }),
keepalive: true,
}).catch(e => console.error('[Umami] Failed to send payload', e));
} catch (error) {
console.error('[Umami] Error sending analytics', error);
}
}
/** /**
* 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) => {
services.analytics.track(eventName, properties); sendAnalyticsPayload('event', {
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) => {
services.analytics.trackPageview(url); sendAnalyticsPayload('event', {
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, LazyMotion, domAnimation } from 'framer-motion'; import { AnimatePresence, m } 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";

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion'; import { m, 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';

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { m, LazyMotion, domAnimation } from 'framer-motion'; import { m } 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';

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useScroll, useTransform, m, LazyMotion, domAnimation } from 'framer-motion'; import { useScroll, useTransform, m } 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';

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, LazyMotion, domAnimation } from 'framer-motion'; import { m } 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';

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, LazyMotion, domAnimation } from 'framer-motion'; import { m } from 'framer-motion';
const ContactMap = dynamic(() => import('@/components/ContactMap'), { const ContactMap = dynamic(() => import('@/components/ContactMap'), {
ssr: false, ssr: false,

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { m, LazyMotion, domAnimation } from 'framer-motion'; import { m } from 'framer-motion';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
export function DataGridPulse() { export function DataGridPulse() {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useScroll, useTransform, m, LazyMotion, domAnimation } from 'framer-motion'; import { useScroll, useTransform, m } from 'framer-motion';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
export function DeepDrillAnimation() { export function DeepDrillAnimation() {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { m, LazyMotion, domAnimation } from 'framer-motion'; import { m } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export function GrowthChart() { export function GrowthChart() {

View File

@@ -3,7 +3,6 @@
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 { m, LazyMotion, domAnimation } 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';
@@ -17,25 +16,7 @@ export interface HeroSectionProps {
ctaHref?: string; ctaHref?: string;
} }
const containerVariants = { // CSS animations replace Framer Motion for better LCP
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;
@@ -47,12 +28,9 @@ 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 && (
<m.div <div
key={`hero-bg-${pathname}`} key={`hero-bg-${pathname}`}
className="absolute inset-0 z-0" className="absolute inset-0 z-0 animate-hero-zoom-out"
initial={{ scale: 1.05, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 1.5, ease: "easeOut" }}
> >
<Image <Image
src={bgSrc} src={bgSrc}
@@ -74,37 +52,34 @@ 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" />
</m.div> </div>
)} )}
<Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}> <Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
<m.div <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 && (
<m.div variants={itemVariants}> <div className="animate-hero-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
<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>
</m.div> </div>
)} )}
<m.div variants={itemVariants}> <div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', animationFillMode: 'both' }}>
<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>
</m.div> </div>
{subtitle && ( {subtitle && (
<m.div variants={itemVariants}> <div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', animationFillMode: 'both' }}>
<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>
</m.div> </div>
)} )}
{ctaLabel && ctaHref && ( {ctaLabel && ctaHref && (
<m.div variants={itemVariants} className={`mt-8 ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`}> <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' }}>
<Button <Button
href={ctaHref} href={ctaHref}
variant="accent" variant="accent"
@@ -115,9 +90,9 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
&rarr; &rarr;
</span> </span>
</Button> </Button>
</m.div> </div>
)} )}
</m.div> </div>
</Container> </Container>
</section> </section>
); );

View File

@@ -86,10 +86,14 @@ export function HeroVideo(props: HeroVideoProps) {
src={posterSrc} src={posterSrc}
alt={posterAlt} alt={posterAlt}
fill fill
className="object-cover z-[1] pointer-events-none filter contrast-125 saturate-110 brightness-90" className="object-cover z-[1] pointer-events-none"
sizes="100vw" sizes="100vw"
quality={25}
priority priority
fetchPriority="high"
/> />
{/* Fast compositing layer to replace the heavy CSS filters on the image */}
<div className="absolute inset-0 bg-black/20 z-[1] pointer-events-none mix-blend-multiply" />
{/* Render video on top if available, but defer src to avoid blocking LCP */} {/* Render video on top if available, but defer src to avoid blocking LCP */}
{videoUrl && ( {videoUrl && (

View File

@@ -1,16 +1,20 @@
'use client'; 'use client';
import React, { useState, useRef, useCallback, useMemo } from 'react'; import React, { useState, useRef, useCallback, useMemo } from 'react';
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { MapPin, ArrowUpRight } from 'lucide-react';
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;
@@ -28,58 +32,7 @@ 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" />
)}
<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 function InteractiveGermanyMap({ export function InteractiveGermanyMap({
badge, badge,
@@ -135,12 +88,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">
<Image <img
src="/germany-map.svg" src="/germany-map.svg"
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"} alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
fill className="w-full h-full object-cover"
className="object-cover" fetchPriority="high"
priority decoding="async"
/> />
</div> </div>
), [locale]); ), [locale]);
@@ -210,116 +163,14 @@ export function InteractiveGermanyMap({
{mapBackground} {mapBackground}
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => ( <InteractiveMapPins
<MinorNode locations={finalLocations}
key={`minor-${loc.id || idx}`} locale={locale}
loc={loc} t={t}
isActive={activeLocation === loc} activeLocation={activeLocation}
onEnter={handleMouseEnter} handleMouseEnter={handleMouseEnter}
onLeave={handleMouseLeave} handleMouseLeave={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 && (
<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"
/>
<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>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,195 @@
'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, LazyMotion, domAnimation } from 'framer-motion'; import { m } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export interface JobListingBlockProps { export interface JobListingBlockProps {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m, LazyMotion, domAnimation } from 'framer-motion'; import { m } 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,19 +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);
// Center the first card on mount so it appears in the middle with peek on both sides
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
// Wait for layout to settle
requestAnimationFrame(() => {
const firstCard = el.querySelector('[data-card]') as HTMLElement | null;
if (!firstCard) return;
const cardCenter = firstCard.offsetLeft + firstCard.offsetWidth / 2;
const containerCenter = el.offsetWidth / 2;
el.scrollLeft = cardCenter - containerCenter;
});
}, [references.length]);
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');
@@ -127,7 +114,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
<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" /> <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 */} {/* Carousel: centered first card with peek on both sides */}
<LazyMotion features={domAnimation}>
<div <div
ref={containerRef} ref={containerRef}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
@@ -169,6 +156,7 @@ 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"
/> />
@@ -188,7 +176,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
})} })}
</div> </div>
</LazyMotion>
</div> </div>
<div className="container relative z-10 mt-4 flex justify-center"> <div className="container relative z-10 mt-4 flex justify-center">

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useScroll, useTransform, m, LazyMotion, domAnimation } from 'framer-motion'; import { useScroll, useTransform, m } from 'framer-motion';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
export function ScaleOfImpact() { export function ScaleOfImpact() {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion'; import { m, Variants } from 'framer-motion';
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay'; import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
export interface ServicePanelData { export interface ServicePanelData {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion'; import { m, 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';
@@ -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) 100vw, 33vw" sizes="(max-width: 768px) 200px, 384px"
/> />
</div> </div>

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, LazyMotion, domAnimation } from 'framer-motion'; import { m } from 'framer-motion';
export interface SupportCTAProps { export interface SupportCTAProps {
title: string; title: string;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m, LazyMotion, domAnimation } from 'framer-motion'; import { m } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';

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, LazyMotion, domAnimation } from 'framer-motion'; import { useScroll, useTransform, useSpring, m } from 'framer-motion';
import { LogoArcs } from '@/components/ui/LogoArcs'; import { LogoArcs } from '@/components/ui/LogoArcs';
function MagneticRing({ function MagneticRing({
@@ -25,34 +25,45 @@ 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;
const rect = ref.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2; if (!ticking) {
const centerY = rect.top + rect.height / 2; window.requestAnimationFrame(() => {
if (!ref.current) {
ticking = false;
return;
}
const rect = ref.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const 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 const actualDistance = Math.sqrt(distX * distX + distY * distY);
// Calculate actual distance correctly if (actualDistance < pullRadius) {
const actualDistance = Math.sqrt(distX * distX + distY * distY); // Exponential falloff for natural magnetic feel
const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2);
if (actualDistance < pullRadius) {
// Exponential falloff for natural magnetic feel // Target offset (fraction of the distance based on strength)
const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2); x.set(distX * pullFactor * (pullStrength / 100));
y.set(distY * pullFactor * (pullStrength / 100));
// Target offset (fraction of the distance based on strength) } else {
x.set(distX * pullFactor * (pullStrength / 100)); // Return to origin smoothly
y.set(distY * pullFactor * (pullStrength / 100)); x.set(0);
} else { y.set(0);
// Return to origin smoothly }
x.set(0); ticking = false;
y.set(0); });
ticking = true;
} }
}; };
window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mousemove', handleMouseMove, { passive: true });
return () => window.removeEventListener('mousemove', handleMouseMove); return () => window.removeEventListener('mousemove', handleMouseMove);
}, [x, y, pullRadius, pullStrength]); }, [x, y, pullRadius, pullStrength]);

View File

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

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { m, LazyMotion, domAnimation } from 'framer-motion'; import { m } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
export function EUFundingBadge() { export function EUFundingBadge() {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { m, LazyMotion, domAnimation } from 'framer-motion'; import { m } 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';

View File

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

View File

@@ -111,6 +111,7 @@ 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

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, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { m, AnimatePresence } from 'framer-motion';
export interface NavLink { export interface NavLink {
label: string; label: string;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
'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, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { m, AnimatePresence } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { m, AnimatePresence } from 'framer-motion';
import { useTransition } from './TransitionProvider'; import { useTransition } from './TransitionProvider';
import Image from 'next/image'; import Image from 'next/image';

View File

@@ -2,7 +2,6 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'; import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { LazyMotion, domAnimation } from 'framer-motion';
interface TransitionContextType { interface TransitionContextType {
isTransitioning: boolean; isTransitioning: boolean;
@@ -69,9 +68,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
setTransitionMessage={setTransitionMessage} setTransitionMessage={setTransitionMessage}
/> />
</React.Suspense> </React.Suspense>
<LazyMotion features={domAnimation}> {children}
{children}
</LazyMotion>
</TransitionContext.Provider> </TransitionContext.Provider>
); );
} }

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, LazyMotion, domAnimation } from 'framer-motion'; import { useMotionValue, useTransform, animate, useInView, m } from 'framer-motion';
interface AnimatedCounterProps { interface AnimatedCounterProps {
value: number; value: number;

View File

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

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useInView, m, LazyMotion, domAnimation } from 'framer-motion'; import { useInView, m } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
/** /**

1
dummy-polyfill.js Normal file
View File

@@ -0,0 +1 @@
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 const onRouterTransitionStart = Sentry.captureRouterTransitionStart; export {};

11921
lh-desktop.json Normal file

File diff suppressed because one or more lines are too long

11867
lh-report.json Normal file

File diff suppressed because one or more lines are too long

11818
lh-standorte.json Normal file

File diff suppressed because one or more lines are too long

2
lib/empty-polyfill.js Normal file
View File

@@ -0,0 +1,2 @@
// Empty polyfill to trick Next.js into not loading legacy polyfills
export default {};

View File

@@ -5,7 +5,9 @@ import { join } from 'path';
* Loads the Inter fonts for use in Satori (Next.js OG Image generation). * Loads the Inter fonts for use in Satori (Next.js OG Image generation).
* Since we are using runtime = 'nodejs', we can read them from the filesystem. * Since we are using runtime = 'nodejs', we can read them from the filesystem.
*/ */
let cachedFonts: any[] | null = null;
export async function getOgFonts() { export async function getOgFonts() {
if (cachedFonts) return cachedFonts;
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff'); const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff');
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff'); const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff');
@@ -28,7 +30,7 @@ export async function getOgFonts() {
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`, `[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
); );
return [ cachedFonts = [
{ {
name: 'Inter', name: 'Inter',
data: boldFont, data: boldFont,
@@ -42,28 +44,35 @@ export async function getOgFonts() {
style: 'normal' as const, style: 'normal' as const,
}, },
]; ];
return cachedFonts;
} catch (error) { } catch (error) {
console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error); console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error);
return []; return [];
} }
} }
let cachedBackground: string | null = null;
export function getOgBackground() { export function getOgBackground() {
if (cachedBackground) return cachedBackground;
try { try {
const bgPath = join(process.cwd(), 'public/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg'); const bgPath = join(process.cwd(), 'public/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg');
const bgBase64 = readFileSync(bgPath, 'base64'); const bgBase64 = readFileSync(bgPath, 'base64');
return `data:image/jpeg;base64,${bgBase64}`; cachedBackground = `data:image/jpeg;base64,${bgBase64}`;
return cachedBackground;
} catch (err) { } catch (err) {
console.error('[OG] Failed to load background', err); console.error('[OG] Failed to load background', err);
return undefined; return undefined;
} }
} }
let cachedLogo: string | null = null;
export function getOgLogo() { export function getOgLogo() {
if (cachedLogo) return cachedLogo;
try { try {
const logoPath = join(process.cwd(), 'public/assets/logo-white.png'); const logoPath = join(process.cwd(), 'public/assets/logo-white.png');
const logoBase64 = readFileSync(logoPath, 'base64'); const logoBase64 = readFileSync(logoPath, 'base64');
return `data:image/png;base64,${logoBase64}`; cachedLogo = `data:image/png;base64,${logoBase64}`;
return cachedLogo;
} catch (err) { } catch (err) {
console.error('[OG] Failed to load logo', err); console.error('[OG] Failed to load logo', err);
return undefined; return undefined;

View File

@@ -1,7 +1,6 @@
import { config } from './config'; import { config } from './config';
const getSiteUrl = () => { const getSiteUrl = () => {
if (process.env.CI) return 'http://etib.localhost';
return (config.baseUrl as string) || 'https://e-tib.com'; return (config.baseUrl as string) || 'https://e-tib.com';
}; };

View File

@@ -2,7 +2,6 @@ import { AppServices } from './app-services';
import { NoopAnalyticsService } from './analytics/noop-analytics-service'; import { NoopAnalyticsService } from './analytics/noop-analytics-service';
import { UmamiAnalyticsService } from './analytics/umami-analytics-service'; import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
import { MemoryCacheService } from './cache/memory-cache-service'; import { MemoryCacheService } from './cache/memory-cache-service';
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
import { NoopErrorReportingService } from './errors/noop-error-reporting-service'; import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
import { NoopLoggerService } from './logging/noop-logger-service'; import { NoopLoggerService } from './logging/noop-logger-service';
import { PinoLoggerService } from './logging/pino-logger-service'; import { PinoLoggerService } from './logging/pino-logger-service';
@@ -68,17 +67,7 @@ export function getAppServices(): AppServices {
logger.info('Notification service initialized (noop)'); logger.info('Notification service initialized (noop)');
// Create error reporting service (GlitchTip/Sentry or no-op) // Create error reporting service (GlitchTip/Sentry or no-op)
const errors = sentryEnabled const errors = new NoopErrorReportingService();
? new GlitchtipErrorReportingService(
{
enabled: true,
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 0.1, // Default to 10% sampling
},
logger,
notifications,
)
: new NoopErrorReportingService();
if (sentryEnabled) { if (sentryEnabled) {
logger.info( logger.info(

View File

@@ -73,7 +73,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : ''; const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
await this.notifications.notify({ await this.notifications.notify({
title: '🔥 Critical Error Captured', title: '🔥 [E-TIB] Critical Error Captured',
message: `Error: ${errorMessage}${contextStr}`, message: `Error: ${errorMessage}${contextStr}`,
priority: 7, priority: 7,
}); });
@@ -92,7 +92,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
(level === 'error' || level === 'fatal' || message.includes('Route Not Found')) (level === 'error' || level === 'fatal' || message.includes('Route Not Found'))
) { ) {
await this.notifications.notify({ await this.notifications.notify({
title: level === 'warning' ? '⚠️ Warning Captured' : '🔥 Critical Message Captured', title: level === 'warning' ? '⚠️ [E-TIB] Warning Captured' : '🔥 [E-TIB] Critical Message Captured',
message: message, message: message,
priority: level === 'warning' ? 5 : 7, priority: level === 'warning' ? 5 : 7,
}); });

View File

@@ -1,4 +1,4 @@
import withMintelConfig from '@mintel/next-config'; import { baseNextConfig } from '@mintel/next-config';
import withBundleAnalyzer from '@next/bundle-analyzer'; import withBundleAnalyzer from '@next/bundle-analyzer';
import { withSentryConfig } from '@sentry/nextjs'; import { withSentryConfig } from '@sentry/nextjs';
@@ -15,6 +15,7 @@ const nextConfig = {
pagesBufferLength: 2, pagesBufferLength: 2,
}, },
experimental: { experimental: {
inlineCss: true,
staleTimes: { staleTimes: {
dynamic: 0, dynamic: 0,
static: 30, static: 30,
@@ -23,7 +24,6 @@ const nextConfig = {
'lucide-react', 'lucide-react',
'framer-motion', 'framer-motion',
'@/components/ui', '@/components/ui',
'@sentry/nextjs',
'react-hook-form', 'react-hook-form',
'zod', 'zod',
'date-fns', 'date-fns',
@@ -47,7 +47,18 @@ const nextConfig = {
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || process.env.npm_package_version || 'dev', NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || process.env.npm_package_version || 'dev',
}, },
// Prevent webpack from restarting when .env files are touched via Docker volume mount // Prevent webpack from restarting when .env files are touched via Docker volume mount
webpack: (config, { dev }) => { webpack: (config, { dev, isServer }) => {
// Completely strip Next.js forced legacy polyfills for modern browser targets
if (!isServer) {
config.resolve.alias = {
...config.resolve.alias,
'next/dist/build/polyfills/polyfill-module': false,
'next/dist/build/polyfills/polyfill-nomodule': false,
'core-js': false,
'regenerator-runtime': false,
};
}
if (dev) { if (dev) {
config.watchOptions = { config.watchOptions = {
...config.watchOptions, ...config.watchOptions,
@@ -538,14 +549,58 @@ const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true', enabled: process.env.ANALYZE === 'true',
}); });
// withMintelConfig internally sets output:'standalone' and wraps with withSentryConfig.
// In dev mode, standalone triggers @vercel/nft file tracing on every compile through // In dev mode, standalone triggers @vercel/nft file tracing on every compile through
// VirtioFS, which hammers CPU. Strip it for dev. // VirtioFS, which hammers CPU. Strip it for dev.
const withMDX = createMDX({}); const withMDX = createMDX({});
let resolvedConfig = withMDX(withAnalyzer(withMintelConfig(nextConfig))); let resolvedConfig = withMDX(withAnalyzer(nextConfig));
// Apply next-intl manually (bypassing withMintelConfig to control Sentry)
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
resolvedConfig = withNextIntl(resolvedConfig);
// Removed withSentryConfig completely to eliminate the 170KB client-side bloat and Array.from legacy polyfill.
// Server-side instrumentation is still handled manually in instrumentation.ts.
// Manually merge baseNextConfig properties
const finalConfig = {
...baseNextConfig,
...resolvedConfig,
images: {
...baseNextConfig.images,
...(resolvedConfig.images || {}),
remotePatterns: [
...(baseNextConfig.images?.remotePatterns || []),
...(resolvedConfig.images?.remotePatterns || [])
]
},
rewrites: async () => {
let baseRewrites = [];
if (typeof baseNextConfig.rewrites === 'function') {
baseRewrites = await baseNextConfig.rewrites();
}
let configRewrites = { beforeFiles: [], fallback: [], afterFiles: [] };
if (typeof resolvedConfig.rewrites === 'function') {
const res = await resolvedConfig.rewrites();
if (Array.isArray(res)) {
configRewrites.fallback = res;
} else {
configRewrites = res;
}
}
return {
beforeFiles: configRewrites.beforeFiles || [],
fallback: [
...(configRewrites.fallback || []),
...baseRewrites
],
afterFiles: configRewrites.afterFiles || []
};
}
};
if (!isProd) { if (!isProd) {
delete resolvedConfig.output; delete finalConfig.output;
} }
export default resolvedConfig; export default finalConfig;

View File

@@ -105,6 +105,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"postinstall": "echo '' > node_modules/next/dist/build/polyfills/polyfill-module.js && echo '' > node_modules/next/dist/build/polyfills/polyfill-nomodule.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts", "test:og": "vitest run tests/og-image.test.ts",
@@ -138,7 +139,7 @@
"prepare": "husky", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"version": "2.2.45", "version": "2.4.24",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",
@@ -149,13 +150,15 @@
], ],
"overrides": { "overrides": {
"next": "16.1.6", "next": "16.1.6",
"minimatch": ">=10.2.2" "minimatch": ">=10.2.2",
"framer-motion": "^12.38.0"
} }
}, },
"browserslist": [ "browserslist": [
"last 3 versions", "last 2 chrome versions",
"not dead", "last 2 firefox versions",
"not ie 11" "last 2 edge versions",
"safari >= 15.4"
], ],
"peerDependencies": { "peerDependencies": {
"lucide-react": "^0.563.0" "lucide-react": "^0.563.0"

40
pnpm-lock.yaml generated
View File

@@ -7,6 +7,7 @@ settings:
overrides: overrides:
next: 16.1.6 next: 16.1.6
minimatch: '>=10.2.2' minimatch: '>=10.2.2'
framer-motion: ^12.38.0
importers: importers:
@@ -52,7 +53,7 @@ importers:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
framer-motion: framer-motion:
specifier: ^12.34.0 specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
graphql: graphql:
specifier: ^16.12.0 specifier: ^16.12.0
@@ -4843,20 +4844,6 @@ packages:
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@11.18.2:
resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
framer-motion@12.38.0: framer-motion@12.38.0:
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
peerDependencies: peerDependencies:
@@ -6142,15 +6129,9 @@ packages:
module-details-from-path@1.0.4: module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
motion-dom@11.18.1:
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
motion-dom@12.38.0: motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
motion-utils@11.18.1:
resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==}
motion-utils@12.36.0: motion-utils@12.36.0:
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
@@ -9246,7 +9227,7 @@ snapshots:
dependencies: dependencies:
'@medv/finder': 4.0.2 '@medv/finder': 4.0.2
clsx: 2.1.1 clsx: 2.1.1
framer-motion: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
html2canvas: 1.4.1 html2canvas: 1.4.1
lucide-react: 0.441.0(react@19.2.4) lucide-react: 0.441.0(react@19.2.4)
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)
@@ -12941,15 +12922,6 @@ snapshots:
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
framer-motion@11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 11.18.1
motion-utils: 11.18.1
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
motion-dom: 12.38.0 motion-dom: 12.38.0
@@ -14577,16 +14549,10 @@ snapshots:
module-details-from-path@1.0.4: {} module-details-from-path@1.0.4: {}
motion-dom@11.18.1:
dependencies:
motion-utils: 11.18.1
motion-dom@12.38.0: motion-dom@12.38.0:
dependencies: dependencies:
motion-utils: 12.36.0 motion-utils: 12.36.0
motion-utils@11.18.1: {}
motion-utils@12.36.0: {} motion-utils@12.36.0: {}
mri@1.2.0: {} mri@1.2.0: {}

39
psi-standorte.json Normal file
View File

@@ -0,0 +1,39 @@
{
"error": {
"code": 429,
"message": "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'pagespeedonline.googleapis.com' for consumer 'project_number:583797351490'.",
"errors": [
{
"message": "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'pagespeedonline.googleapis.com' for consumer 'project_number:583797351490'.",
"domain": "global",
"reason": "rateLimitExceeded"
}
],
"status": "RESOURCE_EXHAUSTED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "RATE_LIMIT_EXCEEDED",
"domain": "googleapis.com",
"metadata": {
"service": "pagespeedonline.googleapis.com",
"quota_location": "global",
"quota_limit_value": "0",
"consumer": "projects/583797351490",
"quota_metric": "pagespeedonline.googleapis.com/default",
"quota_limit": "defaultPerDayPerProject",
"quota_unit": "1/d/{project}"
}
},
{
"@type": "type.googleapis.com/google.rpc.Help",
"links": [
{
"description": "Request a higher quota limit.",
"url": "https://cloud.google.com/docs/quotas/help/request_increase"
}
]
}
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 155 KiB

36
run-lh.cjs Normal file
View File

@@ -0,0 +1,36 @@
const fs = require('fs');
const { execSync } = require('child_process');
console.log("Running Lighthouse...");
try {
execSync('npx lighthouse "https://e-tib.com/de/standorte?v=2" --chrome-flags="--headless --no-sandbox" --output json --output-path ./lh-report.json', { stdio: 'inherit' });
} catch(e) {
console.log("Lighthouse run failed", e.message);
}
const data = JSON.parse(fs.readFileSync('./lh-report.json', 'utf8'));
const score = data.categories.performance.score * 100;
console.log("\n==================================");
console.log(`PERFORMANCE SCORE: ${score}`);
console.log("==================================\n");
console.log("FAILING METRICS (Score < 1.0):");
Object.values(data.audits).filter(a => a.score !== null && a.score < 1).forEach(a => {
console.log(`- ${a.id}: ${a.score} (${a.displayValue || 'No display value'})`);
if (a.details && a.details.items && a.details.items.length > 0) {
if (a.id === 'image-delivery-insight' || a.id === 'unused-javascript' || a.id === 'modern-image-formats' || a.id === 'uses-responsive-images') {
console.log(" Items:");
a.details.items.forEach(item => {
console.log(` URL: ${item.url || item.node?.nodeLabel || 'unknown'}`);
if (item.wastedBytes) console.log(` Wasted KB: ${(item.wastedBytes / 1024).toFixed(2)}`);
if (item.wastedMs) console.log(` Wasted Ms: ${item.wastedMs}`);
});
}
}
});
console.log("\nLCP BREAKDOWN:");
const lcpElement = data.audits['largest-contentful-paint-element'];
if (lcpElement && lcpElement.details && lcpElement.details.items) {
console.log("LCP Element:", lcpElement.details.items[0].node.snippet);
}

36
run-lh.js Normal file
View File

@@ -0,0 +1,36 @@
const fs = require('fs');
const { execSync } = require('child_process');
console.log("Running Lighthouse...");
try {
execSync('npx lighthouse "https://e-tib.com/de/standorte?v=2" --chrome-flags="--headless --no-sandbox" --output json --output-path ./lh-report.json', { stdio: 'inherit' });
} catch(e) {
console.log("Lighthouse run failed", e.message);
}
const data = JSON.parse(fs.readFileSync('./lh-report.json', 'utf8'));
const score = data.categories.performance.score * 100;
console.log("\n==================================");
console.log(`PERFORMANCE SCORE: ${score}`);
console.log("==================================\n");
console.log("FAILING METRICS (Score < 1.0):");
Object.values(data.audits).filter(a => a.score !== null && a.score < 1).forEach(a => {
console.log(`- ${a.id}: ${a.score} (${a.displayValue || 'No display value'})`);
if (a.details && a.details.items && a.details.items.length > 0) {
if (a.id === 'image-delivery-insight' || a.id === 'unused-javascript' || a.id === 'modern-image-formats' || a.id === 'uses-responsive-images') {
console.log(" Items:");
a.details.items.forEach(item => {
console.log(` URL: ${item.url || item.node?.nodeLabel || 'unknown'}`);
if (item.wastedBytes) console.log(` Wasted KB: ${item.wastedBytes / 1024}`);
if (item.wastedMs) console.log(` Wasted Ms: ${item.wastedMs}`);
});
}
}
});
console.log("\nLCP BREAKDOWN:");
const lcpElement = data.audits['largest-contentful-paint-element'];
if (lcpElement && lcpElement.details && lcpElement.details.items) {
console.log("LCP Element:", lcpElement.details.items[0].node.snippet);
}

View File

@@ -6,13 +6,14 @@ module.exports = async (browser, context) => {
const page = await browser.newPage(); const page = await browser.newPage();
// Using LHCI_URL or TARGET_URL if available // Using LHCI_URL or TARGET_URL if available
const targetUrl = const targetUrl =
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.klz-cables.com'; process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.e-tib.com';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie for ${new URL(targetUrl).hostname}...`); console.log(`🔑 LHCI Auth: Setting gatekeeper cookie (${authCookieName}) for ${new URL(targetUrl).hostname}...`);
await page.setCookie({ await page.setCookie({
name: 'klz_gatekeeper_session', name: authCookieName,
value: gatekeeperPassword, value: gatekeeperPassword,
domain: new URL(targetUrl).hostname, domain: new URL(targetUrl).hostname,
path: '/', path: '/',

View File

@@ -19,6 +19,7 @@ const targetUrl =
'http://localhost:3000'; 'http://localhost:3000';
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
async function main() { async function main() {
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`); console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
@@ -32,7 +33,7 @@ async function main() {
// We might need to bypass gatekeeper for the sitemap fetch too // We might need to bypass gatekeeper for the sitemap fetch too
const response = await axios.get(sitemapUrl, { const response = await axios.get(sitemapUrl, {
headers: { headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`, Cookie: `${authCookieName}=${gatekeeperPassword}`,
}, },
validateStatus: (status) => status < 400, validateStatus: (status) => status < 400,
}); });
@@ -77,7 +78,7 @@ async function main() {
// Handle authentication for staging/testing // Handle authentication for staging/testing
// Lighthouse can set cookies via --collect.settings.extraHeaders // Lighthouse can set cookies via --collect.settings.extraHeaders
const extraHeaders = JSON.stringify({ const extraHeaders = JSON.stringify({
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`, Cookie: `${authCookieName}=${gatekeeperPassword}`,
}); });
// Detect Chrome path from Puppeteer installation if not provided // Detect Chrome path from Puppeteer installation if not provided
@@ -147,7 +148,7 @@ async function main() {
execSync(lhciCommand, { execSync(lhciCommand, {
encoding: 'utf8', encoding: 'utf8',
stdio: 'inherit', stdio: 'inherit',
env: { ...process.env, CHROME_PATH: chromePath }, env: { ...process.env, CHROME_PATH: chromePath, TARGET_URL: targetUrl },
}); });
} catch (err: any) { } catch (err: any) {
console.warn('⚠️ LHCI assertion finished with warnings or errors.'); console.warn('⚠️ LHCI assertion finished with warnings or errors.');

View File

@@ -70,7 +70,9 @@ while IFS= read -r run; do
elif [ "$CURRENT_PRIO" -lt "$RUN_PRIO" ]; then elif [ "$CURRENT_PRIO" -lt "$RUN_PRIO" ]; then
echo "Current workflow has LOWER priority ($CURRENT_PRIO < $RUN_PRIO). Aborting self." echo "Current workflow has LOWER priority ($CURRENT_PRIO < $RUN_PRIO). Aborting self."
curl -s -X POST -H "Authorization: token $GITEA_PAT" "$API_BASE/$CURRENT_RUN_ID/cancel" curl -s -X POST -H "Authorization: token $GITEA_PAT" "$API_BASE/$CURRENT_RUN_ID/cancel"
exit 1 echo "Waiting for Gitea to process cancellation..."
sleep 15
exit 0
else else
# Equal priority: Newer wins # Equal priority: Newer wins
if [ "$CURRENT_RUN_ID" -gt "$RUN_ID" ]; then if [ "$CURRENT_RUN_ID" -gt "$RUN_ID" ]; then
@@ -79,7 +81,9 @@ while IFS= read -r run; do
else else
echo "Equal priority, but current is older. Aborting self." echo "Equal priority, but current is older. Aborting self."
curl -s -X POST -H "Authorization: token $GITEA_PAT" "$API_BASE/$CURRENT_RUN_ID/cancel" curl -s -X POST -H "Authorization: token $GITEA_PAT" "$API_BASE/$CURRENT_RUN_ID/cancel"
exit 1 echo "Waiting for Gitea to process cancellation..."
sleep 15
exit 0
fi fi
fi fi
done <<< "$RUNS" done <<< "$RUNS"

9
skip_branch_script.sh Normal file
View File

@@ -0,0 +1,9 @@
if [[ "${{ github.ref_type }}" == "branch" && "${{ github.ref_name }}" != "main" && "${{ github.ref_name }}" != "master" ]]; then
# Fetch tags from remote for this specific commit to check if it's being tagged simultaneously
TAGS=$(git ls-remote --tags origin | grep "${{ github.sha }}" || true)
if [[ -n "$TAGS" ]]; then
echo "Dieser Commit hat einen Tag! Überspringe Preview-Deployment, damit der Tag-Deploy Vorrang hat."
echo "target=skip" >> $GITHUB_OUTPUT
exit 0
fi
fi