Compare commits

..

1 Commits

Author SHA1 Message Date
1955bfcd1b perf(lcp): bypass Next.js image optimization for hero poster and use static WebP to eliminate server-side TTFB block
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 QA (push) Successful in 1m51s
Build & Deploy / 🏗️ Build (push) Successful in 3m29s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m14s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-01 01:15:18 +02:00
62 changed files with 220 additions and 891 deletions

View File

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

3
.env
View File

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

View File

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

View File

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

View File

@@ -47,7 +47,14 @@ jobs:
run: | run: |
echo "=== System Disk Usage ===" echo "=== System Disk Usage ==="
df -h || true df -h || true
echo "=== Tool Cache Usage ==="
du -sh /opt/hostedtoolcache/* || true
du -sh /opt/hostedtoolcache/.[!.]* || true
echo "Purging old tool caches skipped to prevent runner corruption." echo "Purging old tool caches skipped to prevent runner corruption."
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 ==="
@@ -333,9 +340,6 @@ jobs:
{ {
echo "# Generated by CI - $TARGET" echo "# Generated by CI - $TARGET"
echo "IMAGE_TAG=$IMAGE_TAG" echo "IMAGE_TAG=$IMAGE_TAG"
if [[ "$TARGET" != "production" ]]; then
echo "NODE_TLS_REJECT_UNAUTHORIZED=0"
fi
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL" echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN" echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
echo "SENTRY_DSN=$SENTRY_DSN" echo "SENTRY_DSN=$SENTRY_DSN"

4
.gitignore vendored
View File

@@ -40,7 +40,3 @@ next-env.d.ts
# turborepo # turborepo
.turbo .turbo
.turbo-test .turbo-test
# scratch
scratch/

View File

@@ -1,7 +1,7 @@
FROM node:20-alpine FROM node:20-alpine
# Install essential build tools if needed (e.g., for node-gyp) # Install essential build tools if needed (e.g., for node-gyp)
RUN apk add --no-cache libc6-compat python3 make g++ curl git RUN apk add --no-cache libc6-compat python3 make g++ curl
WORKDIR /app WORKDIR /app

View File

@@ -1,6 +1,4 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { getImageProps } from 'next/image';
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
import { Container, Badge, Heading } from '@/components/ui'; import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
@@ -51,34 +49,10 @@ const mdxComponents = {
JsonLd, JsonLd,
Button, Button,
Badge, Badge,
Heading,
AnimatedCounter, AnimatedCounter,
GrowthChart, GrowthChart,
DeepDrillAnimation, DeepDrillAnimation,
DataGridPulse, DataGridPulse,
ScaleOfImpact,
ResponsiveImage: (props: any) => {
let src = props.src;
if (typeof src === 'string' && src.startsWith('/_next/image')) {
try {
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
if (urlParam) src = decodeURIComponent(urlParam);
} catch (_e) {
// Ignore invalid URL parsing
}
}
const { props: { srcSet, src: finalSrc, sizes } } = getImageProps({
src,
alt: props.alt || '',
width: 1920,
height: 1080,
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
quality: 80,
});
return (
<img {...props} src={finalSrc} srcSet={srcSet} sizes={sizes} loading="lazy" decoding="async" />
);
},
// Standard HTML element mapping for consistent E-TIB typography // Standard HTML element mapping for consistent E-TIB typography
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1 h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />, h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,

View File

@@ -1,6 +1,4 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { getImageProps } from 'next/image';
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { import {
@@ -26,29 +24,6 @@ const mdxComponents = {
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />, h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />, h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />,
h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />, h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />,
ScaleOfImpact,
ResponsiveImage: (props: any) => {
let src = props.src;
if (typeof src === 'string' && src.startsWith('/_next/image')) {
try {
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
if (urlParam) src = decodeURIComponent(urlParam);
} catch (_e) {
// Ignore invalid URL parsing
}
}
const { props: { srcSet, src: finalSrc, sizes } } = getImageProps({
src,
alt: props.alt || '',
width: 1920,
height: 1080,
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
quality: 80,
});
return (
<img {...props} src={finalSrc} srcSet={srcSet} sizes={sizes} loading="lazy" decoding="async" />
);
},
p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-normal max-w-3xl" {...props} />, p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-normal max-w-3xl" {...props} />,
ul: (props: any) => <ul className="list-none mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />, ul: (props: any) => <ul className="list-none mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />, ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
@@ -143,7 +118,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
{/* Title overlay on image */} {/* Title overlay on image */}
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24"> <div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
<div className="container mx-auto max-w-7xl px-4"> <div className="container mx-auto px-4">
<div className="max-w-4xl bg-neutral-dark/40 backdrop-blur-md p-6 rounded-2xl border border-white/10"> <div className="max-w-4xl bg-neutral-dark/40 backdrop-blur-md p-6 rounded-2xl border border-white/10">
{post.frontmatter.category && ( {post.frontmatter.category && (
<div className="overflow-hidden mb-6"> <div className="overflow-hidden mb-6">
@@ -217,7 +192,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
)} )}
{/* Main Content Area with Sticky Narrative Layout */} {/* Main Content Area with Sticky Narrative Layout */}
<div className="container mx-auto max-w-7xl px-4 py-16 md:py-24"> <div className="container mx-auto px-4 py-16 md:py-24">
<div className="sticky-narrative-container"> <div className="sticky-narrative-container">
{/* Left Column: Content */} {/* Left Column: Content */}
<div className="sticky-narrative-content"> <div className="sticky-narrative-content">

View File

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

View File

@@ -1,6 +1,4 @@
import { Container, Heading, Badge } from '@/components/ui'; import { Container, Heading, Badge } from '@/components/ui';
import { getImageProps } from 'next/image';
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getAllReferences } from '@/lib/references'; import { getAllReferences } from '@/lib/references';
@@ -41,30 +39,6 @@ const mdxComponents = {
ul: (props: any) => <ul className="grid grid-cols-1 gap-2 mb-6" {...props} />, ul: (props: any) => <ul className="grid grid-cols-1 gap-2 mb-6" {...props} />,
li: CustomLi, li: CustomLi,
p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />, p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />,
Heading,
ScaleOfImpact,
ResponsiveImage: (props: any) => {
let src = props.src;
if (typeof src === 'string' && src.startsWith('/_next/image')) {
try {
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
if (urlParam) src = decodeURIComponent(urlParam);
} catch (_e) {
// Ignore invalid URL parsing
}
}
const { props: { srcSet, src: finalSrc, sizes } } = getImageProps({
src,
alt: props.alt || '',
width: 1920,
height: 1080,
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
quality: 80,
});
return (
<img {...props} src={finalSrc} srcSet={srcSet} sizes={sizes} loading="lazy" decoding="async" />
);
},
}; };
interface PageProps { interface PageProps {

View File

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

View File

@@ -81,7 +81,7 @@ export default function ContactForm() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<Heading level={3} align="center" className="mb-4"> <Heading level={3} className="mb-4">
{t('form.successTitle') || 'Message Sent!'} {t('form.successTitle') || 'Message Sent!'}
</Heading> </Heading>
<p className="text-text-secondary text-lg mb-8"> <p className="text-text-secondary text-lg mb-8">
@@ -115,7 +115,7 @@ export default function ContactForm() {
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" /> <line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</div> </div>
<Heading level={3} align="center" className="mb-4 text-destructive font-black"> <Heading level={3} className="mb-4 text-destructive font-black">
{t('form.errorTitle') || 'Submission Failed!'} {t('form.errorTitle') || 'Submission Failed!'}
</Heading> </Heading>
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium"> <p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">

View File

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

View File

@@ -25,7 +25,7 @@ interface CertificatesBlockProps {
hideHeader?: boolean; hideHeader?: boolean;
} }
export const defaultCertificates: Certificate[] = [ const defaultCertificates: Certificate[] = [
{ {
title: 'ISO 14001:2015', title: 'ISO 14001:2015',
description: 'Umweltmanagementsystem', description: 'Umweltmanagementsystem',
@@ -47,13 +47,6 @@ export const defaultCertificates: Certificate[] = [
type: 'iso', type: 'iso',
date: '05.12.2025', date: '05.12.2025',
}, },
{
title: 'Betriebshaftpflichtversicherung',
description: 'RheinLand Versicherungen (10 Mio. € Deckung)',
pdfUrl: '/assets/certificates/betriebshaftpflicht.pdf',
type: 'general',
date: '25.03.2026',
},
{ {
title: 'Freistellungsbescheinigung', title: 'Freistellungsbescheinigung',
description: 'Nach § 48 b EStG', description: 'Nach § 48 b EStG',
@@ -75,6 +68,7 @@ export const defaultCertificates: Certificate[] = [
type: 'tax', type: 'tax',
date: '13.02.2025', date: '13.02.2025',
}, },
]; ];
export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) { export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) {
@@ -154,8 +148,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
const wrapperProps = cert.pdfUrl ? { const wrapperProps = cert.pdfUrl ? {
href: encodeURI(cert.pdfUrl), href: encodeURI(cert.pdfUrl),
target: "_blank", target: "_blank",
rel: "noopener noreferrer", rel: "noopener noreferrer"
download: cert.pdfUrl.split('/').pop()
} : {}; } : {};
return ( return (
@@ -208,7 +201,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
</p> </p>
</div> </div>
<div className={`mt-auto inline-flex items-center gap-2 text-sm font-bold uppercase tracking-wider transition-all duration-300 ${ <div className={`mt-auto inline-flex items-center text-sm font-bold uppercase tracking-wider transition-all ${
cert.pdfUrl ? 'group-hover:gap-3' : '' cert.pdfUrl ? 'group-hover:gap-3' : ''
} ${ } ${
isIso ? 'text-primary-light' : 'text-primary' isIso ? 'text-primary-light' : 'text-primary'
@@ -216,7 +209,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
{cert.pdfUrl ? ( {cert.pdfUrl ? (
<> <>
<span>Download PDF</span> <span>Download PDF</span>
<Download size={16} className="group-hover:translate-y-0.5 transition-transform" /> <Download size={16} className="ml-2 group-hover:translate-y-0.5 transition-transform" />
</> </>
) : ( ) : (
<span className="text-neutral-400">Nachweis liegt vor</span> <span className="text-neutral-400">Nachweis liegt vor</span>

View File

@@ -85,7 +85,7 @@ export function CompanyTimeline({
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" /> <div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" />
<div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" /> <div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" />
<div className="container mx-auto max-w-5xl relative z-10"> <div className="container max-w-5xl relative z-10">
<div className="text-center mb-12 md:mb-24"> <div className="text-center mb-12 md:mb-24">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-neutral-200 text-neutral-dark text-xs font-bold uppercase tracking-wider mb-6 shadow-sm"> <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-neutral-200 text-neutral-dark text-xs font-bold uppercase tracking-wider mb-6 shadow-sm">
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" /> <span className="w-2 h-2 rounded-full bg-primary animate-pulse" />

View File

@@ -60,26 +60,26 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`} className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
> >
{badge && ( {badge && (
<div className="animate-hero-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}> <div className="animate-hero-fade-in-up" style={{ animationDelay: '0.1s', opacity: 0, animationFillMode: 'forwards' }}>
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg"> <Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
{badge} {badge}
</Badge> </Badge>
</div> </div>
)} )}
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', animationFillMode: 'both' }}> <div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', opacity: 0, animationFillMode: 'forwards' }}>
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8"> <Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
{title} {title}
</Heading> </Heading>
</div> </div>
{subtitle && ( {subtitle && (
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', animationFillMode: 'both' }}> <div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', opacity: 0, animationFillMode: 'forwards' }}>
<p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}> <p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}>
{subtitle} {subtitle}
</p> </p>
</div> </div>
)} )}
{ctaLabel && ctaHref && ( {ctaLabel && ctaHref && (
<div className={`mt-8 animate-hero-fade-in-up ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`} style={{ animationDelay: badge ? '0.55s' : '0.4s', animationFillMode: 'both' }}> <div className={`mt-8 animate-hero-fade-in-up ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`} style={{ animationDelay: badge ? '0.55s' : '0.4s', opacity: 0, animationFillMode: 'forwards' }}>
<Button <Button
href={ctaHref} href={ctaHref}
variant="accent" variant="accent"

View File

@@ -34,7 +34,7 @@ export function HeroVideo(props: HeroVideoProps) {
let defaultPoster = "/assets/photos/DJI_0048.JPG"; let defaultPoster = "/assets/photos/DJI_0048.JPG";
if (videoUrl && videoUrl.endsWith('.mp4')) { if (videoUrl && videoUrl.endsWith('.mp4')) {
defaultPoster = videoUrl.replace('.mp4', '-poster.jpg'); defaultPoster = videoUrl.replace('.mp4', '-poster.webp');
} }
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || defaultPoster; const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || defaultPoster;
@@ -88,7 +88,7 @@ export function HeroVideo(props: HeroVideoProps) {
fill fill
className="object-cover z-[1] pointer-events-none" className="object-cover z-[1] pointer-events-none"
sizes="100vw" sizes="100vw"
quality={25} unoptimized={true}
priority priority
fetchPriority="high" fetchPriority="high"
/> />
@@ -119,7 +119,7 @@ export function HeroVideo(props: HeroVideoProps) {
{/* Top Fade for Header Navigation Readability */} {/* Top Fade for Header Navigation Readability */}
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" /> <div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
<div className="container mx-auto max-w-7xl relative z-[4] text-center px-4"> <div className="container relative z-[4] text-center px-4">
<div <div
key={`hero-content-${pathname}`} key={`hero-content-${pathname}`}
className="relative" className="relative"

View File

@@ -53,7 +53,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
showFairs, showFairs,
fairsTitle = t('fairsTitle'), fairsTitle = t('fairsTitle'),
fairs = [ fairs = [
{ name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Energie-Messe', location: 'Messe München' }, { name: 'Intersolar München', date: 'Termin folgt', type: 'Energie-Messe', location: 'Messe München' },
{ name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' }, { name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' },
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' } { name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
], ],
@@ -69,7 +69,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
return ( return (
<div className="py-12 md:py-24"> <div className="py-12 md:py-24">
<div className="container max-w-7xl mx-auto"> <div className="container mx-auto">
{showFairs && ( {showFairs && (
<div className="mb-12 md:mb-24 relative"> <div className="mb-12 md:mb-24 relative">
<m.h3 <m.h3

View File

@@ -98,7 +98,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
<section id="referenzen" className="pt-16 pb-8 md:py-24 lg:py-32 bg-neutral-dark text-white relative overflow-hidden"> <section id="referenzen" className="pt-16 pb-8 md:py-24 lg:py-32 bg-neutral-dark text-white relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" /> <div className="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" />
<div className="container mx-auto max-w-7xl relative z-10 mb-12 flex flex-col items-center justify-center text-center gap-6"> <div className="container relative z-10 mb-12 flex flex-col items-center justify-center text-center gap-6">
<div> <div>
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2> <h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
<h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3> <h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
@@ -179,7 +179,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
</div> </div>
<div className="container mx-auto max-w-7xl relative z-10 mt-4 flex justify-center"> <div className="container relative z-10 mt-4 flex justify-center">
<Link <Link
href={ctaHref} href={ctaHref}
className="px-8 py-4 rounded-xl bg-white/5 border border-white/20 hover:bg-white hover:text-neutral-dark hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] transition-all font-bold flex items-center gap-3 backdrop-blur-sm" className="px-8 py-4 rounded-xl bg-white/5 border border-white/20 hover:bg-white hover:text-neutral-dark hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] transition-all font-bold flex items-center gap-3 backdrop-blur-sm"

View File

@@ -69,7 +69,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
return ( return (
<section id="unternehmen" className="pt-12 pb-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden"> <section id="unternehmen" className="pt-12 pb-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
<div className="container mx-auto max-w-7xl relative z-10 px-4"> <div className="container relative z-10 px-4">
{(badge || title) && ( {(badge || title) && (
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16"> <div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">

View File

@@ -1,12 +1,9 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect } from 'react'; import { m } from 'framer-motion';
import { createPortal } from 'react-dom';
import { m, AnimatePresence } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { LogoArcs } from '@/components/ui/LogoArcs';
export interface TeamMember { export interface TeamMember {
id: string; id: string;
@@ -25,105 +22,14 @@ interface TeamGridProps {
members: TeamMember[]; members: TeamMember[];
} }
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
export function getMemberLogoArcsProps(memberId: string) {
const hash = hashString(memberId);
const rotation = Math.round((hash * 137.5) % 360);
const size = 160 + ((hash * 13) % 180); // 160px to 340px
const top = -60 + ((hash * 7) % 80); // -60px to 20px
const right = (hash % 2 === 0) ? -50 + ((hash * 11) % 70) : undefined;
const left = (hash % 2 !== 0) ? -50 + ((hash * 11) % 70) : undefined;
const opacity = Number((0.015 + ((hash % 7) * 0.005)).toFixed(3)); // 0.015 to 0.045 (ultra subtle)
return { rotation, size, top, right, left, opacity };
}
export function TeamGrid({ members }: TeamGridProps) { export function TeamGrid({ members }: TeamGridProps) {
const t = useTranslations('TeamGrid'); const t = useTranslations('TeamGrid');
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
const [mounted, setMounted] = useState(false);
const [[page, direction], setPage] = useState([0, 0]);
const selectedIndex = selectedMember ? members.findIndex(m => m.id === selectedMember.id) : -1;
const prevMember = selectedIndex !== -1 && members.length > 1 ? members[(selectedIndex - 1 + members.length) % members.length] : null;
const nextMember = selectedIndex !== -1 && members.length > 1 ? members[(selectedIndex + 1) % members.length] : null;
const paginate = (newDirection: number) => {
if (newDirection > 0 && nextMember) {
setPage([page + 1, newDirection]);
setSelectedMember(nextMember);
} else if (newDirection < 0 && prevMember) {
setPage([page - 1, newDirection]);
setSelectedMember(prevMember);
}
};
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectedMember(null);
} else if (e.key === 'ArrowLeft' && prevMember) {
paginate(-1);
} else if (e.key === 'ArrowRight' && nextMember) {
paginate(1);
}
};
window.addEventListener('keydown', handleKeyDown);
if (selectedMember) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [selectedMember, prevMember, nextMember, page]);
const handlePreload = (url: string | null) => {
if (url && typeof window !== 'undefined') {
const img = new window.Image();
img.src = url;
}
};
if (!members || members.length === 0) return null; if (!members || members.length === 0) return null;
const slideVariants = {
enter: (dir: number) => ({
x: dir > 0 ? 350 : dir < 0 ? -350 : 0,
opacity: 0,
scale: 0.82,
}),
center: {
x: 0,
opacity: 1,
scale: 1,
},
exit: (dir: number) => ({
x: dir < 0 ? 350 : dir > 0 ? -350 : 0,
opacity: 0,
scale: 0.82,
}),
};
return ( return (
<section className="py-16 md:py-24 lg:py-32 bg-white relative overflow-hidden"> <section className="py-16 md:py-24 lg:py-32 bg-white relative overflow-hidden">
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl relative z-10"> <div className="container relative z-10">
<div className="mb-12"> <div className="mb-12">
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2> <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
<h3 className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-4">{t('title')}</h3> <h3 className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-4">{t('title')}</h3>
@@ -133,353 +39,75 @@ export function TeamGrid({ members }: TeamGridProps) {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 xl:gap-10"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 xl:gap-10">
{members.map((member, i) => { {members.map((member, i) => (
const imageUrl = member.image ? (typeof member.image === 'string' ? member.image : member.image.url) : null; <m.div
const arcsProps = getMemberLogoArcsProps(member.id); key={member.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden"
>
{/* Card Banner */}
<div className="h-32 bg-neutral-50 relative border-b border-neutral-100 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-neutral-100 to-neutral-200/50" />
<div className="absolute inset-0 opacity-[0.03] pointer-events-none" style={{ backgroundImage: "url('data:image/svg+xml,%3Csvg width=\\'20\\' height=\\'20\\' viewBox=\\'0 0 20 20\\' xmlns=\\'http://www.w3.org/2000/svg\\'%3E%3Crect width=\\'1\\' height=\\'1\\' fill=\\'%23000000\\'/%3E%3C/svg%3E')" }} />
</div>
return ( <div className="px-8 pb-8 relative flex-grow flex flex-col">
<m.div {/* Overlapping Profile Picture */}
key={member.id} <div className="w-24 h-24 rounded-2xl overflow-hidden border-4 border-white shadow-xl bg-white relative -mt-12 mb-6 group-hover:-translate-y-2 transition-transform duration-500">
initial={{ opacity: 0, y: 20 }} {member.image && (typeof member.image === 'string' ? member.image : member.image.url) ? (
whileInView={{ opacity: 1, y: 0 }} <Image
viewport={{ once: true, margin: "-50px" }} src={typeof member.image === 'string' ? member.image : member.image.url}
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }} alt={member.name}
onMouseEnter={() => handlePreload(imageUrl)} fill
onTouchStart={() => handlePreload(imageUrl)} sizes="96px"
className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden" className="object-cover"
> />
{/* Card Banner with Signature E-TIB LogoArcs Design Element */} ) : (
<div className="h-44 sm:h-52 md:h-36 bg-neutral-50 relative border-b border-neutral-100 overflow-hidden select-none"> <div className="absolute inset-0 flex items-center justify-center text-neutral-300 bg-neutral-50">
<div className="absolute inset-0 bg-gradient-to-br from-neutral-100/90 via-neutral-50 to-orange-50/20" /> <svg className="w-10 h-10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
{/* Signature E-TIB Logo Arcs Design Element */}
<div
className="absolute pointer-events-none transition-transform duration-700 group-hover:scale-110 text-primary"
style={{
width: `${arcsProps.size}px`,
height: `${arcsProps.size}px`,
top: `${arcsProps.top}px`,
right: arcsProps.right !== undefined ? `${arcsProps.right}px` : undefined,
left: arcsProps.left !== undefined ? `${arcsProps.left}px` : undefined,
transform: `rotate(${arcsProps.rotation}deg)`,
opacity: arcsProps.opacity,
}}
>
<LogoArcs className="w-full h-full" />
</div>
<div className="absolute inset-0 opacity-[0.025] pointer-events-none" style={{ backgroundImage: "url('data:image/svg+xml,%3Csvg width=\\'20\\' height=\\'20\\' viewBox=\\'0 0 20 20\\' xmlns=\\'http://www.w3.org/2000/svg\\'%3E%3Crect width=\\'1\\' height=\\'1\\' fill=\\'%23000000\\'/%3E%3C/svg%3E')" }} />
</div>
<div className="px-6 sm:px-8 pb-8 relative flex-grow flex flex-col items-center md:items-start text-center md:text-left">
{/* Overlapping Profile Picture (Extra Large Centered on Mobile, Lightbox modal active only on Desktop) */}
<m.div
layoutId={`team-avatar-${member.id}`}
onClick={() => {
if (typeof window !== 'undefined' && window.innerWidth >= 768) {
setPage([0, 0]);
setSelectedMember(member);
}
}}
onMouseEnter={() => handlePreload(imageUrl)}
onTouchStart={() => handlePreload(imageUrl)}
className="w-52 h-64 sm:w-60 sm:h-72 md:w-36 md:h-44 mx-auto md:mx-0 rounded-2xl overflow-hidden border-4 border-white shadow-xl bg-white relative -mt-28 md:-mt-24 mb-6 transition-transform duration-500 cursor-default md:cursor-pointer group/avatar"
>
{imageUrl ? (
<>
<Image
src={imageUrl}
alt={member.name}
fill
sizes="(max-width: 768px) 300px, 144px"
className="object-cover object-[center_15%] transition-transform duration-500 group-hover/avatar:scale-105"
/>
{/* Zoom Icon Overlay on Hover (Desktop only) */}
<div className="hidden md:flex absolute inset-0 bg-black/40 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 items-center justify-center text-white cursor-pointer">
<svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center text-neutral-300 bg-neutral-50 group-hover/avatar:bg-neutral-100 transition-colors">
<svg className="w-16 h-16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
<div className="hidden md:flex absolute inset-0 bg-black/30 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 items-center justify-center text-white cursor-pointer">
<svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</div>
</div>
)}
</m.div>
{/* Details */}
<h4 className="font-heading font-extrabold text-2xl text-neutral-dark mb-1 group-hover:text-primary transition-colors duration-300 leading-tight">{member.name}</h4>
<p className="text-primary font-bold text-xs uppercase tracking-widest mb-6">{member.position}</p>
{member.branch && (
<div className="mb-6">
<span className="inline-block px-3 py-1 bg-neutral-100 text-neutral-600 rounded-md text-xs uppercase tracking-widest font-semibold border border-neutral-200/60">
{member.branch === 'e-tib' ? t('branchETIB') : member.branch === 'ing' ? t('branchIng') : member.branch === 'bohrtechnik' ? t('branchBohr') : member.branch}
</span>
</div> </div>
)} )}
{/* Contacts (Sticky at bottom) */}
<div className="mt-auto pt-6 border-t border-neutral-100 flex flex-col gap-4 w-full text-left">
{member.email && (
<a href={`mailto:${member.email}`} className="group/link flex items-center gap-4 text-text-secondary hover:text-primary transition-colors font-medium cursor-pointer">
<div className="w-10 h-10 rounded-xl bg-neutral-50 flex items-center justify-center text-neutral-400 group-hover/link:bg-primary/10 group-hover/link:text-primary transition-colors border border-neutral-100">
<svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
</div>
<span className="truncate">{member.email}</span>
</a>
)}
{member.phone && (
<a href={`tel:${member.phone}`} className="group/link flex items-center gap-4 text-text-secondary hover:text-primary transition-colors font-medium cursor-pointer">
<div className="w-10 h-10 rounded-xl bg-neutral-50 flex items-center justify-center text-neutral-400 group-hover/link:bg-primary/10 group-hover/link:text-primary transition-colors border border-neutral-100">
<svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
</div>
<span>{member.phone}</span>
</a>
)}
</div>
</div> </div>
</m.div>
);
})}
</div>
</div>
{/* 3D Carousel Lightbox Modal with Neighbor Blurred Preview Cards & React Portal */} {/* Details */}
{mounted && createPortal( <h4 className="font-heading font-extrabold text-2xl text-neutral-dark mb-1 group-hover:text-primary transition-colors duration-300 leading-tight">{member.name}</h4>
<AnimatePresence> <p className="text-primary font-bold text-xs uppercase tracking-widest mb-6">{member.position}</p>
{selectedMember && (
<m.div {member.branch && (
initial={{ opacity: 0 }} <div className="mb-6">
animate={{ opacity: 1 }} <span className="inline-block px-3 py-1 bg-neutral-100 text-neutral-600 rounded-md text-xs uppercase tracking-widest font-semibold border border-neutral-200/60">
exit={{ opacity: 0 }} {member.branch === 'e-tib' ? t('branchETIB') : member.branch === 'ing' ? t('branchIng') : member.branch === 'bohrtechnik' ? t('branchBohr') : member.branch}
onClick={() => setSelectedMember(null)} </span>
className="fixed inset-0 z-[9999] bg-black/85 backdrop-blur-xl flex flex-col items-center justify-center p-4 sm:p-6 md:p-12 overflow-y-auto" </div>
>
{/* Close Button */}
<button
onClick={() => setSelectedMember(null)}
className="absolute top-4 right-4 sm:top-6 sm:right-6 z-50 w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-white/10 hover:bg-white/30 text-white flex items-center justify-center backdrop-blur-md transition-colors cursor-pointer"
aria-label="Close"
>
<svg className="w-5 h-5 sm:w-6 sm:h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
{/* Desktop Left Arrow Navigation Button */}
{prevMember && (
<button
onClick={(e) => {
e.stopPropagation();
paginate(-1);
}}
className="hidden md:flex absolute left-4 md:left-8 z-40 w-12 h-12 rounded-full bg-white/10 hover:bg-white/30 text-white items-center justify-center backdrop-blur-md transition-all duration-300 hover:scale-110 cursor-pointer"
aria-label="Previous Team Member"
>
<svg className="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
)}
{/* Desktop Right Arrow Navigation Button */}
{nextMember && (
<button
onClick={(e) => {
e.stopPropagation();
paginate(1);
}}
className="hidden md:flex absolute right-4 md:right-8 z-40 w-12 h-12 rounded-full bg-white/10 hover:bg-white/30 text-white items-center justify-center backdrop-blur-md transition-all duration-300 hover:scale-110 cursor-pointer"
aria-label="Next Team Member"
>
<svg className="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
)}
{/* 3D Carousel Stage */}
<div className="relative w-full max-w-6xl flex items-center justify-center">
{/* Previous Neighbor Card (Blurred Background Left - Desktop only) */}
{prevMember && (
<m.div
key={`prev-${prevMember.id}`}
layout
onClick={(e) => {
e.stopPropagation();
paginate(-1);
}}
className="hidden lg:flex flex-col absolute -left-12 xl:-left-20 w-64 lg:w-72 rounded-3xl overflow-hidden bg-white/10 border border-white/20 backdrop-blur-md opacity-35 hover:opacity-70 scale-90 transition-all duration-500 cursor-pointer z-10 shadow-2xl select-none"
>
<div className="relative w-full aspect-[3/4] bg-neutral-900 overflow-hidden">
{prevMember.image && (
<Image
src={typeof prevMember.image === 'string' ? prevMember.image : prevMember.image.url}
alt={prevMember.name}
fill
sizes="300px"
className="object-cover blur-[2px]"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
<div className="absolute bottom-4 left-4 right-4 text-white">
<p className="font-heading font-extrabold text-lg truncate">{prevMember.name}</p>
<p className="text-neutral-300 text-xs truncate">{prevMember.position}</p>
</div>
</div>
</m.div>
)} )}
{/* Active Main Card with Directional Slide Animation into Center */} {/* Contacts (Sticky at bottom) */}
<AnimatePresence custom={direction} mode="popLayout"> <div className="mt-auto pt-6 border-t border-neutral-100 flex flex-col gap-4">
<m.div {member.email && (
key={selectedMember.id} <a href={`mailto:${member.email}`} className="group/link flex items-center gap-4 text-text-secondary hover:text-primary transition-colors font-medium">
custom={direction} <div className="w-10 h-10 rounded-xl bg-neutral-50 flex items-center justify-center text-neutral-400 group-hover/link:bg-primary/10 group-hover/link:text-primary transition-colors border border-neutral-100">
variants={slideVariants} <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
initial="enter"
animate="center"
exit="exit"
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.6}
onDragEnd={(_, info) => {
if (Math.abs(info.offset.y) > 100 || Math.abs(info.velocity.y) > 500) {
setSelectedMember(null);
}
}}
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.25 },
scale: { duration: 0.25 },
}}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-[2rem] sm:rounded-3xl overflow-hidden max-w-[380px] sm:max-w-md md:max-w-lg w-full shadow-2xl border border-white/20 relative flex flex-col z-20 my-auto touch-none cursor-grab active:cursor-grabbing shrink-0"
>
{/* Mobile Drag Indicator Bar */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-30 w-12 h-1.5 rounded-full bg-white/50 backdrop-blur-sm pointer-events-none" />
{/* Portrait Container - Immersive Extra Large Portrait Aspect Framing for Mobile */}
<m.div
layoutId={`team-avatar-${selectedMember.id}`}
className="relative w-full h-[60vh] min-h-[440px] max-h-[620px] md:h-[460px] bg-neutral-900 overflow-hidden shrink-0"
>
{selectedMember.image ? (
<Image
src={typeof selectedMember.image === 'string' ? selectedMember.image : selectedMember.image.url}
alt={selectedMember.name}
fill
sizes="(max-width: 768px) 100vw, 600px"
className="object-cover object-[center_15%] pointer-events-none select-none"
priority
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-neutral-400 bg-neutral-800">
<svg className="w-24 h-24 mb-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-transparent to-transparent pointer-events-none" />
<div className="absolute bottom-5 left-5 right-5 text-white pointer-events-none">
<h3 className="font-heading font-extrabold text-2xl sm:text-3xl drop-shadow-md">{selectedMember.name}</h3>
<p className="text-primary-light font-semibold text-xs sm:text-sm uppercase tracking-widest drop-shadow-sm">{selectedMember.position}</p>
</div> </div>
</m.div> <span className="truncate">{member.email}</span>
</a>
{/* Contact Info Footer */} )}
<div className="p-5 sm:p-6 bg-white flex flex-col gap-3"> {member.phone && (
{selectedMember.email && ( <a href={`tel:${member.phone}`} className="group/link flex items-center gap-4 text-text-secondary hover:text-primary transition-colors font-medium">
<a href={`mailto:${selectedMember.email}`} className="flex items-center gap-3 text-neutral-700 hover:text-primary font-medium transition-colors cursor-pointer text-sm sm:text-base"> <div className="w-10 h-10 rounded-xl bg-neutral-50 flex items-center justify-center text-neutral-400 group-hover/link:bg-primary/10 group-hover/link:text-primary transition-colors border border-neutral-100">
<div className="w-9 h-9 rounded-lg bg-neutral-100 flex items-center justify-center text-neutral-500 shrink-0"> <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
<svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
</div>
<span className="truncate">{selectedMember.email}</span>
</a>
)}
{selectedMember.phone && (
<a href={`tel:${selectedMember.phone}`} className="flex items-center gap-3 text-neutral-700 hover:text-primary font-medium transition-colors cursor-pointer text-sm sm:text-base">
<div className="w-9 h-9 rounded-lg bg-neutral-100 flex items-center justify-center text-neutral-500 shrink-0">
<svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
</div>
<span>{selectedMember.phone}</span>
</a>
)}
</div>
</m.div>
</AnimatePresence>
{/* Next Neighbor Card (Blurred Background Right - Desktop only) */}
{nextMember && (
<m.div
key={`next-${nextMember.id}`}
layout
onClick={(e) => {
e.stopPropagation();
paginate(1);
}}
className="hidden lg:flex flex-col absolute -right-12 xl:-right-20 w-64 lg:w-72 rounded-3xl overflow-hidden bg-white/10 border border-white/20 backdrop-blur-md opacity-35 hover:opacity-70 scale-90 transition-all duration-500 cursor-pointer z-10 shadow-2xl select-none"
>
<div className="relative w-full aspect-[3/4] bg-neutral-900 overflow-hidden">
{nextMember.image && (
<Image
src={typeof nextMember.image === 'string' ? nextMember.image : nextMember.image.url}
alt={nextMember.name}
fill
sizes="300px"
className="object-cover blur-[2px]"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
<div className="absolute bottom-4 left-4 right-4 text-white">
<p className="font-heading font-extrabold text-lg truncate">{nextMember.name}</p>
<p className="text-neutral-300 text-xs truncate">{nextMember.position}</p>
</div> </div>
</div> <span>{member.phone}</span>
</m.div> </a>
)} )}
</div> </div>
{/* Mobile Dedicated Navigation Bar (Bottom) */}
<div
onClick={(e) => e.stopPropagation()}
className="flex md:hidden items-center justify-between w-full max-w-md mt-4 px-4 py-2.5 bg-white/10 backdrop-blur-xl rounded-full border border-white/20 text-white z-40 shrink-0"
>
<button
onClick={() => paginate(-1)}
className="w-10 h-10 rounded-full bg-white/20 active:bg-white/40 flex items-center justify-center text-white cursor-pointer"
aria-label="Previous"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<span className="text-xs font-bold uppercase tracking-widest text-white/90">
{selectedIndex + 1} / {members.length}
</span>
<button
onClick={() => paginate(1)}
className="w-10 h-10 rounded-full bg-white/20 active:bg-white/40 flex items-center justify-center text-white cursor-pointer"
aria-label="Next"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
</div> </div>
</m.div> </m.div>
)} ))}
</AnimatePresence>, </div>
document.body </div>
)}
</section> </section>
); );
} }

View File

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

View File

@@ -104,7 +104,7 @@ layout: "fullBleed"
icon: "hdd", icon: "hdd",
lists: [ lists: [
[ [
"Horizontalspülbohrverfahren (bis 400m)", "Horizontalspülbohrverfahren (bis 250m)",
"Horizontalspülbohrverfahren (bis 400er Rohr)", "Horizontalspülbohrverfahren (bis 400er Rohr)",
"Erdrakete (bis 15m DÖ-Länge)", "Erdrakete (bis 15m DÖ-Länge)",
"Erdrakete (bis 160er Rohr)" "Erdrakete (bis 160er Rohr)"

View File

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

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Unternehmen" badge="Unternehmen"
title="Unser Team" title="Unser Team"
subtitle="Die Experten hinter der E-TIB Gruppe" subtitle="Die Experten hinter der E-TIB Gruppe"
backgroundImage={{ url: '/assets/photos/E-TIB_Gruppenfoto.jpg', focalY: 25 }} backgroundImage={{ url: '/assets/photos/DJI_0048.JPG' }}
alignment="center" alignment="center"
/> />
@@ -23,14 +23,6 @@ layout: "fullBleed"
phone: "+49 1520 7230518", phone: "+49 1520 7230518",
image: "/assets/photos/team/danny.jpg" image: "/assets/photos/team/danny.jpg"
}, },
{
id: "kathrin-lemanski",
name: "Kathrin Lemanski",
position: "Kaufmännische Leitung",
email: "k.lemanski@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kathrin.jpg"
},
{ {
id: "dirk-schmidt", id: "dirk-schmidt",
name: "Dirk Schmidt", name: "Dirk Schmidt",
@@ -55,6 +47,22 @@ layout: "fullBleed"
phone: "+49 171 3868167", phone: "+49 171 3868167",
image: "/assets/photos/team/martin.jpg" image: "/assets/photos/team/martin.jpg"
}, },
{
id: "kerstin-joseph",
name: "Kerstin Joseph",
position: "Sekretariat / Buchhaltung",
email: "k.joseph@e-tib.com",
phone: "+49 3561 6851692",
image: "/assets/photos/team/kerstin.jpg"
},
{
id: "kathrin-lemanski",
name: "Kathrin Lemanski",
position: "Sachbearbeitung",
email: "k.lemanski@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kathrin.jpg"
},
{ {
id: "sven-kirbs", id: "sven-kirbs",
name: "Sven Kirbs", name: "Sven Kirbs",
@@ -63,21 +71,6 @@ layout: "fullBleed"
phone: "+49 151 29603652", phone: "+49 151 29603652",
image: "/assets/photos/team/sven.jpg" image: "/assets/photos/team/sven.jpg"
}, },
{
id: "kerstin-joseph",
name: "Kerstin Joseph",
position: "Buchhaltung / Controlling",
email: "k.joseph@e-tib.com",
phone: "+49 3561 6857733"
},
{
id: "katrin-haigold",
name: "Katrin Haigold",
position: "Buchhaltung / Personalwesen",
email: "k.haigold@e-tib.com",
phone: "+49 3561 6851692",
image: "/assets/photos/team/katrin-heigold.jpg"
},
{ {
id: "maik-weichert", id: "maik-weichert",
name: "Maik Weichert", name: "Maik Weichert",

View File

@@ -104,7 +104,7 @@ layout: "fullBleed"
icon: "hdd", icon: "hdd",
lists: [ lists: [
[ [
"Horizontal directional drilling (up to 400m)", "Horizontal directional drilling (up to 250m)",
"Horizontal directional drilling (up to 400mm pipe)", "Horizontal directional drilling (up to 400mm pipe)",
"Earth rocket (up to 15m crossing length)", "Earth rocket (up to 15m crossing length)",
"Earth rocket (up to 160mm pipe)" "Earth rocket (up to 160mm pipe)"

View File

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

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Company" badge="Company"
title="Our Team" title="Our Team"
subtitle="The Experts behind the E-TIB Group" subtitle="The Experts behind the E-TIB Group"
backgroundImage={{ url: '/assets/photos/E-TIB_Gruppenfoto.jpg', focalY: 25 }} backgroundImage={{ url: '/assets/photos/DJI_0048.JPG' }}
alignment="center" alignment="center"
/> />
@@ -23,14 +23,6 @@ layout: "fullBleed"
phone: "+49 1520 7230518", phone: "+49 1520 7230518",
image: "/assets/photos/team/danny.jpg" image: "/assets/photos/team/danny.jpg"
}, },
{
id: "kathrin-lemanski",
name: "Kathrin Lemanski",
position: "Commercial Management",
email: "k.lemanski@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kathrin.jpg"
},
{ {
id: "dirk-schmidt", id: "dirk-schmidt",
name: "Dirk Schmidt", name: "Dirk Schmidt",
@@ -55,6 +47,22 @@ layout: "fullBleed"
phone: "+49 171 3868167", phone: "+49 171 3868167",
image: "/assets/photos/team/martin.jpg" image: "/assets/photos/team/martin.jpg"
}, },
{
id: "kerstin-joseph",
name: "Kerstin Joseph",
position: "Secretariat / Accounting",
email: "k.joseph@e-tib.com",
phone: "+49 3561 6851692",
image: "/assets/photos/team/kerstin.jpg"
},
{
id: "kathrin-lemanski",
name: "Kathrin Lemanski",
position: "Administration",
email: "k.lemanski@e-tib.com",
phone: "+49 3561 6857733",
image: "/assets/photos/team/kathrin.jpg"
},
{ {
id: "sven-kirbs", id: "sven-kirbs",
name: "Sven Kirbs", name: "Sven Kirbs",
@@ -63,21 +71,6 @@ layout: "fullBleed"
phone: "+49 151 29603652", phone: "+49 151 29603652",
image: "/assets/photos/team/sven.jpg" image: "/assets/photos/team/sven.jpg"
}, },
{
id: "kerstin-joseph",
name: "Kerstin Joseph",
position: "Accounting / Controlling",
email: "k.joseph@e-tib.com",
phone: "+49 3561 6857733"
},
{
id: "katrin-haigold",
name: "Katrin Haigold",
position: "Accounting / Human Resources",
email: "k.haigold@e-tib.com",
phone: "+49 3561 6851692",
image: "/assets/photos/team/katrin-heigold.jpg"
},
{ {
id: "maik-weichert", id: "maik-weichert",
name: "Maik Weichert", name: "Maik Weichert",

View File

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

View File

@@ -8,7 +8,7 @@ services:
- infra - infra
- default - default
labels: labels:
- "caddy=http://${TRAEFIK_HOST:-etib.localhost}, http://e-tib.localhost" - "caddy=http://${TRAEFIK_HOST:-etib.localhost}"
- "caddy.reverse_proxy=http://etib-app:3001" - "caddy.reverse_proxy=http://etib-app:3001"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@@ -34,9 +34,11 @@ services:
# Force Garbage Collection before Docker kills the container (OOM) # Force Garbage Collection before Docker kills the container (OOM)
NODE_OPTIONS: "--max-old-space-size=6144" NODE_OPTIONS: "--max-old-space-size=6144"
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
UV_THREADPOOL_SIZE: "1"
RAYON_NUM_THREADS: "1"
NEXT_PRIVATE_WORKER_THREADS: "false"
NPM_TOKEN: ${NPM_TOKEN:-} NPM_TOKEN: ${NPM_TOKEN:-}
CI: "true" CI: "true"
HUSKY: "0"
WATCHPACK_POLLING: "true" WATCHPACK_POLLING: "true"
ports: ports:
- "3001:3001" - "3001:3001"
@@ -46,6 +48,7 @@ services:
- etib_next_cache:/app/.next - etib_next_cache:/app/.next
- etib_turbo_cache:/app/.turbo - etib_turbo_cache:/app/.turbo
- etib_pnpm_store:/pnpm - etib_pnpm_store:/pnpm
- /app/.git
- /app/reference - /app/reference
- /app/data - /app/data
@@ -54,7 +57,19 @@ services:
limits: limits:
memory: 8G memory: 8G
command: > command: >
sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0 --port 3001" sh -c "pnpm install --no-frozen-lockfile &&
while true; do
(
echo '[warmup] Waiting for Next.js to be reachable...'
until curl -sf http://localhost:3001 > /dev/null; do sleep 2; done
echo '[warmup] Server is up! Pre-compiling routes...'
curl -sf http://localhost:3001/de > /dev/null 2>&1 && echo '[warmup] /de ready'
echo '[warmup] All routes pre-compiled ✓'
) &
pnpm next dev --webpack --hostname 0.0.0.0 --port 3001;
echo '[etib-app] next dev exited, restarting in 2s...';
sleep 2;
done"
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3001" - "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3001"
@@ -62,7 +77,7 @@ services:
networks: networks:
default: default:
name: ${COMPOSE_PROJECT_NAME:-etib}-dev-internal name: ${PROJECT_NAME:-e-tib}-internal
infra: infra:
external: true external: true

View File

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

View File

@@ -1,8 +1,7 @@
{ {
"/assets/photos/DJI_0048.JPG": "data:image/webp;base64,UklGRjIAAABXRUJQVlA4ICYAAADwAQCdASoKAAcABUB8JYwCdH8AGDXtwwAA92kbyv6vgOVMTIAAAA==", "/assets/photos/DJI_0048.JPG": "data:image/webp;base64,UklGRjIAAABXRUJQVlA4ICYAAADwAQCdASoKAAcABUB8JYwCdH8AGDNIbaAA92kbyv6vgOVMTIAAAA==",
"/assets/photos/E-TIB_Gruppenfoto.jpg": "data:image/webp;base64,UklGRiwAAABXRUJQVlA4ICAAAACQAQCdASoKAAUABUB8JZQCdAEOgwAA4XqSiWtFZ0AAAA==", "/assets/videos/web/hero-bahnkreuzung-poster.jpg": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoKAAYABUB8JaQAA3AA/vDUoAA=",
"/assets/videos/web/hero-bahnkreuzung-poster.jpg": "data:image/webp;base64,UklGRi4AAABXRUJQVlA4ICIAAACQAQCdASoKAAYABUB8JQAAXKXv4AAA/UZNt5FbP3v64AAA", "/assets/videos/web/hero-bohrung-poster.jpg": "data:image/webp;base64,UklGRjIAAABXRUJQVlA4ICYAAACwAQCdASoKAAYABUB8JZACdAEOO2gAAN5Rr3co8j7CDYNKNCAAAA==",
"/assets/videos/web/hero-bohrung-poster.jpg": "data:image/webp;base64,UklGRjIAAABXRUJQVlA4ICYAAACwAQCdASoKAAYABUB8JZACdAEOI+oAAN5Rr3co8j7CDYNKNCAAAA==", "/assets/videos/web/hero-kabelpflug-poster.jpg": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoKAAYABUB8JaQAA3AA/vDUoAA=",
"/assets/videos/web/hero-kabelpflug-poster.jpg": "data:image/webp;base64,UklGRjIAAABXRUJQVlA4ICYAAADQAQCdASoKAAYABUB8JagCw7EOwCTyAAD9QIvsqI74a4qbWwAAAA==", "/assets/videos/web/hero-messe-poster.jpg": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoKAAYABUB8JaQAA3AA/vDUoAA="
"/assets/videos/web/hero-messe-poster.jpg": "data:image/webp;base64,UklGRi4AAABXRUJQVlA4ICIAAABQAQCdASoKAAYABUB8JQBOgC6gAP7hJjEBwUoIEPzDKgAA"
} }

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, // Client-side we usually want lower sample rate
},
logger,
notifications,
)
: new NoopErrorReportingService();
if (sentryEnabled) { if (sentryEnabled) {
logger.info( logger.info(

View File

@@ -56,9 +56,6 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
tracesSampleRate: this.options.tracesSampleRate ?? 0.1, tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1, replaysSessionSampleRate: 0.1,
ignoreErrors: [
'Failed to find Server Action. This request might be from an older or newer deployment.',
],
}); });
} }
return Sentry; return Sentry;

File diff suppressed because one or more lines are too long

View File

@@ -280,7 +280,7 @@
"TeamGrid": { "TeamGrid": {
"badge": "Persönliche Beratung", "badge": "Persönliche Beratung",
"title": "Ihre Ansprechpartner", "title": "Ihre Ansprechpartner",
"subtitle": "Sprechen Sie direkt mit unseren Experten für Ihr Projekt.", "subtitle": "Sprechen Sie direkt mit unseren Experten für Ihr regionales Projekt.",
"management": "Geschäftsführung", "management": "Geschäftsführung",
"branchETIB": "E-TIB GmbH", "branchETIB": "E-TIB GmbH",
"branchIng": "Ingenieurgesellschaft", "branchIng": "Ingenieurgesellschaft",

View File

@@ -280,7 +280,7 @@
"TeamGrid": { "TeamGrid": {
"badge": "Personal Consultation", "badge": "Personal Consultation",
"title": "Your Contacts", "title": "Your Contacts",
"subtitle": "Speak directly with our experts for your project.", "subtitle": "Speak directly with our experts for your regional project.",
"management": "Management", "management": "Management",
"branchETIB": "E-TIB GmbH", "branchETIB": "E-TIB GmbH",
"branchIng": "Engineering Company", "branchIng": "Engineering Company",

View File

@@ -1,6 +1,5 @@
{ {
"name": "e-tib-nextjs", "name": "e-tib-nextjs",
"version": "2.4.56",
"type": "module", "type": "module",
"private": true, "private": true,
"packageManager": "pnpm@10.18.3", "packageManager": "pnpm@10.18.3",
@@ -73,7 +72,6 @@
"@types/sharp": "^0.31.1", "@types/sharp": "^0.31.1",
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react-swc": "^4.3.1",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
@@ -101,7 +99,7 @@
"xlsx-cli": "^1.1.3" "xlsx-cli": "^1.1.3"
}, },
"scripts": { "scripts": {
"dev": "bash -c 'bash scripts/registry-auth.sh && [ -f .env ] || (cp .env.example .env && sed -i.bak \"s/TRAEFIK_HOST=e-tib.com/TRAEFIK_HOST=etib.localhost/\" .env && rm -f .env.bak && echo \"✅ Created .env from .env.example\"); trap \"COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml up --build etib-app etib-proxy --remove-orphans'", "dev": "bash -c 'bash scripts/registry-auth.sh && [ -f .env ] || (cp .env.example .env && sed -i.bak \"s/TRAEFIK_HOST=e-tib.com/TRAEFIK_HOST=etib.localhost/\" .env && rm -f .env.bak && echo \"✅ Created .env from .env.example\"); trap \"COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml up etib-app etib-proxy --remove-orphans'",
"dev:local": "bash -c 'bash scripts/registry-auth.sh && next dev --webpack --port 3100 --hostname 0.0.0.0'", "dev:local": "bash -c 'bash scripts/registry-auth.sh && next dev --webpack --port 3100 --hostname 0.0.0.0'",
"dev:infra": "COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml up -d etib-proxy", "dev:infra": "COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml up -d etib-proxy",
"build": "next build", "build": "next build",
@@ -141,7 +139,7 @@
"prepare": "husky", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"version": "2.4.48", "version": "2.2.94",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",

22
pnpm-lock.yaml generated
View File

@@ -212,9 +212,6 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.2.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 5.2.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitejs/plugin-react-swc':
specifier: ^4.3.1
version: 4.3.1(@swc/helpers@0.5.20)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/ui': '@vitest/ui':
specifier: ^4.0.16 specifier: ^4.0.16
version: 4.1.2(vitest@4.1.2) version: 4.1.2(vitest@4.1.2)
@@ -2355,9 +2352,6 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.3': '@rolldown/pluginutils@1.0.0-rc.3':
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rollup/plugin-commonjs@28.0.1': '@rollup/plugin-commonjs@28.0.1':
resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==}
engines: {node: '>=16.0.0 || 14 >= 14.17'} engines: {node: '>=16.0.0 || 14 >= 14.17'}
@@ -3292,12 +3286,6 @@ packages:
peerDependencies: peerDependencies:
react: '>= 16.8.0' react: '>= 16.8.0'
'@vitejs/plugin-react-swc@4.3.1':
resolution: {integrity: sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^4 || ^5 || ^6 || ^7 || ^8
'@vitejs/plugin-react@5.2.0': '@vitejs/plugin-react@5.2.0':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -10129,8 +10117,6 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.3': {} '@rolldown/pluginutils@1.0.0-rc.3': {}
'@rolldown/pluginutils@1.0.1': {}
'@rollup/plugin-commonjs@28.0.1(rollup@4.60.1)': '@rollup/plugin-commonjs@28.0.1(rollup@4.60.1)':
dependencies: dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1) '@rollup/pluginutils': 5.3.0(rollup@4.60.1)
@@ -11042,14 +11028,6 @@ snapshots:
'@use-gesture/core': 10.3.1 '@use-gesture/core': 10.3.1
react: 19.2.4 react: 19.2.4
'@vitejs/plugin-react-swc@4.3.1(@swc/helpers@0.5.20)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
'@swc/core': 1.15.21(@swc/helpers@0.5.20)
vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@swc/helpers'
'@vitejs/plugin-react@5.2.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': '@vitejs/plugin-react@5.2.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -5,7 +5,6 @@ import sharp from 'sharp';
const PUBLIC_DIR = path.join(process.cwd(), 'public'); const PUBLIC_DIR = path.join(process.cwd(), 'public');
const IMAGES_TO_PROCESS = [ const IMAGES_TO_PROCESS = [
'/assets/photos/DJI_0048.JPG', '/assets/photos/DJI_0048.JPG',
'/assets/photos/E-TIB_Gruppenfoto.jpg',
'/assets/videos/web/hero-bahnkreuzung-poster.jpg', '/assets/videos/web/hero-bahnkreuzung-poster.jpg',
'/assets/videos/web/hero-bohrung-poster.jpg', '/assets/videos/web/hero-bohrung-poster.jpg',
'/assets/videos/web/hero-kabelpflug-poster.jpg', '/assets/videos/web/hero-kabelpflug-poster.jpg',

View File

@@ -96,7 +96,26 @@ module.exports = {
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', 'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', 'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
}, },
container: {
center: true,
padding: {
DEFAULT: '1rem',
sm: '1.5rem',
md: '2rem',
lg: '2.5rem',
xl: '3rem',
'2xl': '4rem',
},
screens: {
xs: '475px',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1400px',
'3xl': '1600px',
},
},
maxWidth: { maxWidth: {
'xs': '20rem', 'xs': '20rem',
'sm': '24rem', 'sm': '24rem',

View File

@@ -1,28 +0,0 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import { defaultCertificates } from '../components/blocks/CertificatesBlock';
import path from 'path';
describe('Certificates & Insurance Integration (DE + EN)', () => {
it('includes the new RheinLand Betriebshaftpflichtversicherung in defaultCertificates (DE)', () => {
const haftpflichtCert = defaultCertificates.find(
(cert) => cert.title === 'Betriebshaftpflichtversicherung'
);
expect(haftpflichtCert).toBeDefined();
expect(haftpflichtCert?.pdfUrl).toBe('/assets/certificates/betriebshaftpflicht.pdf');
expect(haftpflichtCert?.date).toBe('25.03.2026');
});
it('verifies that content/en/zertifikate.mdx includes Commercial Liability Insurance (EN)', () => {
const enMdx = fs.readFileSync(path.join(__dirname, '../content/en/zertifikate.mdx'), 'utf-8');
expect(enMdx).toContain("title: 'Commercial Liability Insurance'");
expect(enMdx).toContain("pdfUrl: '/assets/certificates/betriebshaftpflicht.pdf'");
expect(enMdx).toContain("date: '25.03.2026'");
});
it('verifies that the PDF asset exists in public/assets/certificates/betriebshaftpflicht.pdf', () => {
const filePath = path.join(__dirname, '../public/assets/certificates/betriebshaftpflicht.pdf');
expect(fs.existsSync(filePath)).toBe(true);
});
});

View File

@@ -1,26 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getAppServices } from '../lib/services/create-services';
import { GlitchtipErrorReportingService } from '../lib/services/errors/glitchtip-error-reporting-service';
// Mock config to ensure sentry is enabled
vi.mock('../lib/config', () => ({
config: {
analytics: { umami: { enabled: false } },
errors: { glitchtip: { enabled: true, dsn: 'https://test@glitchtip.infra.mintel.me/5' } },
logging: { level: 'info' },
notifications: { gotify: { enabled: false } },
},
getMaskedConfig: () => ({}),
}));
describe('AppServices (Client)', () => {
beforeEach(() => {
// Reset singleton
globalThis.__appServices = undefined;
});
it('should instantiate GlitchtipErrorReportingService when enabled', () => {
const services = getAppServices();
expect(services.errors).toBeInstanceOf(GlitchtipErrorReportingService);
});
});

View File

@@ -11,7 +11,7 @@ describe('Messen Content and Translations', () => {
// 1. Intersolar München // 1. Intersolar München
expect(content).toContain("name: 'Intersolar München'"); expect(content).toContain("name: 'Intersolar München'");
expect(content).toContain("date: '8. - 10. Juni 2027'"); expect(content).toContain("date: '23. - 25. Juni 2026'");
expect(content).toContain("type: 'Messe'"); expect(content).toContain("type: 'Messe'");
expect(content).toContain("location: 'München'"); expect(content).toContain("location: 'München'");
@@ -34,7 +34,7 @@ describe('Messen Content and Translations', () => {
// 1. Intersolar Munich // 1. Intersolar Munich
expect(content).toContain("name: 'Intersolar Munich'"); expect(content).toContain("name: 'Intersolar Munich'");
expect(content).toContain("date: 'June 810, 2027'"); expect(content).toContain("date: 'June 2325, 2026'");
expect(content).toContain("type: 'Fair'"); expect(content).toContain("type: 'Fair'");
expect(content).toContain("location: 'Munich'"); expect(content).toContain("location: 'Munich'");

View File

@@ -1,20 +0,0 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { describe, it, expect } from 'vitest';
import * as dotenv from 'dotenv';
describe('Sentry Configuration', () => {
it('should have a valid SENTRY_DSN in .env', () => {
// Read the .env file as a string
const envPath = resolve(process.cwd(), '.env');
const envContent = readFileSync(envPath, 'utf8');
// Parse it using dotenv
const envConfig = dotenv.parse(envContent);
// Assert that SENTRY_DSN is defined and not empty
expect(envConfig.SENTRY_DSN).toBeDefined();
expect(envConfig.SENTRY_DSN.length).toBeGreaterThan(0);
expect(envConfig.SENTRY_DSN).toMatch(/^https:\/\/.+@(?:glitchtip|errors)\.infra\.mintel\.me\/\d+$/);
});
});

View File

@@ -1,5 +1,5 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react-swc'; import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],