Compare commits

..

44 Commits

Author SHA1 Message Date
c06bf88381 2.4.17
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 26s
Build & Deploy / 🧪 QA (push) Successful in 1m36s
Build & Deploy / 🏗️ Build (push) Successful in 3m5s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 24s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-03 01:10:59 +02:00
abdd7b1b36 perf(images): cache bust hero poster image to guarantee 16KB delivery 2026-07-03 01:10:53 +02:00
f1dff452c5 2.4.16
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m32s
Build & Deploy / 🏗️ Build (push) Successful in 2m52s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 26s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-03 01:07:09 +02:00
e82b81bbde perf(layout): remove heavy CorporateBackground to eliminate main thread blocking 2026-07-03 01:07:09 +02:00
9377be51f2 2.4.15
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🧪 QA (push) Successful in 2m1s
Build & Deploy / 🏗️ Build (push) Successful in 3m13s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 55s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-03 01:03:11 +02:00
ca12c56782 perf(images): aggressively compress LCP poster and logo 2026-07-03 01:03:10 +02:00
3a8335be34 2.4.14
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🧪 QA (push) Successful in 1m36s
Build & Deploy / 🏗️ Build (push) Successful in 3m7s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m5s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-03 01:00:08 +02:00
52f9774478 perf(hero): split HeroVideo into a Server Component to bypass React Hydration LCP block 2026-07-03 01:00:07 +02:00
db6492207d 2.4.13
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 25s
Build & Deploy / 🏗️ Build (push) Successful in 3m13s
Build & Deploy / 🧪 QA (push) Successful in 1m26s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 57s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-03 00:54:35 +02:00
99ae6d90c2 perf(css): remove inlineCss:false to fix 300ms render-blocking delay 2026-07-03 00:54:35 +02:00
7d53ca5a11 2.4.12
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 55s
Build & Deploy / 🧪 QA (push) Successful in 1m30s
Build & Deploy / 🏗️ Build (push) Successful in 2m45s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-02 22:15:03 +02:00
0331f3f7ec fix(i18n): add missing Team.gallery translations & perf(hero): force eager LCP image 2026-07-02 22:15:03 +02:00
274d013047 2.4.11
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🏗️ Build (push) Successful in 2m42s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 56s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-07-02 22:10:45 +02:00
f1d0c9c5b2 perf: remove template.tsx wrapper to eliminate LCP hydration delay 2026-07-02 22:10:45 +02:00
c121fe0b93 2.4.10
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m27s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 2m14s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-02 22:03:38 +02:00
640a9dfc9f perf: remove framer-motion filter blur animations to fix main thread blocking, compress heavy images 2026-07-02 22:03:37 +02:00
460e27c497 chore: bump version to 2.4.9
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🧪 QA (push) Successful in 1m14s
Build & Deploy / 🏗️ Build (push) Successful in 2m39s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 50s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-07-02 21:03:51 +02:00
e257f8450b 2.4.8 2026-07-02 21:03:43 +02:00
09a1ac47a7 perf: remove old tailwind config and duplicate globals.css 2026-07-02 21:03:34 +02:00
2241775a7f perf: migrate to native Tailwind v4 @theme and disable inlineCss for optimal LCP 2026-07-02 21:03:22 +02:00
7eb9e0cfe6 fix: resolve invalid package.json literal newline character breaking pipeline
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 21s
Build & Deploy / 🏗️ Build (push) Successful in 2m42s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 57s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-02 18:31:37 +02:00
ab4f4af555 chore: bump version to 2.4.7 for final lighthouse fix
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🧪 QA (push) Failing after 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-02 16:35:19 +02:00
b6bd2065bb perf: restore inlineCss for optimal LCP score & add missing pdf-renderer dependency 2026-07-02 16:34:24 +02:00
8d69ac2de2 chore(release): v2.4.6
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 21s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Successful in 2m43s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 50s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-02 15:25:40 +02:00
a9f16b49cc fix(performance): disable inlineCss to reduce HTML size from 557KB to 162KB and fix FCP fluctuations
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 57s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🏗️ Build (push) Successful in 2m41s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-02 12:26:05 +02:00
21cf0725ab release: v2.4.5
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Successful in 2m33s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 53s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-02 10:29:32 +02:00
2c162f5314 perf: completely fix non-composited animations, eliminate shutter unused JS, and aggressively compress images 2026-07-02 10:29:31 +02:00
be3b2e6a7b release: v2.4.4
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 50s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
Build & Deploy / 🏗️ Build (push) Successful in 2m31s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-01 18:23:50 +02:00
f2e59fa52f perf: fix sizes attribute scaling bug to prevent mobile devices from downloading 1920w desktop images 2026-07-01 18:23:50 +02:00
16d494689f release: v2.4.3
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m15s
Build & Deploy / 🏗️ Build (push) Successful in 2m26s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 52s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-01 18:21:25 +02:00
d24b8a8694 perf: prevent bandwidth theft from non-critical preloads and strip InitialLoader chunk 2026-07-01 18:21:25 +02:00
8d42946711 release: v2.4.2
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 2m44s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 50s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-01 18:11:33 +02:00
b15217b9be content: update Hochspannung wording per WhatsApp screenshot 2026-07-01 18:11:33 +02:00
b138025380 release: v2.4.1
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🧪 QA (push) Successful in 1m16s
Build & Deploy / 🏗️ Build (push) Successful in 2m11s
Build & Deploy / 🚀 Deploy (push) Successful in 38s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-01 17:19:41 +02:00
18d2ace03b perf: compress hero image and defer page transition shutter
Some checks failed
Build & Deploy / 🔍 Prepare (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-07-01 17:19:35 +02:00
89ed1faa6b release: v2.4.0
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 21s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 48s
Build & Deploy / 🧪 QA (push) Successful in 1m15s
Build & Deploy / 🏗️ Build (push) Successful in 2m7s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-07-01 15:30:55 +02:00
7307e812a4 release: v2.2.96 2026-07-01 15:30:49 +02:00
20d6194a7e perf: fix massive LCP render delay and TTFB on Home page
Some checks failed
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🔍 Prepare (push) Has been cancelled
2026-07-01 15:30:28 +02:00
86e2fd7097 2.2.95
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 21s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
Build & Deploy / 🏗️ Build (push) Successful in 2m14s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 49s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-01 13:35:58 +02:00
bf8073f5bf perf: optimize LCP images and enable AVIF
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m10s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 2m2s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-01 13:35:50 +02:00
e8adb87b02 fix(lcp): revert DynamicFramerMotion wrapper to prevent React 18 Suspense DOM deletion during hydration
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
Build & Deploy / 🧪 QA (push) Failing after 48s
2026-07-01 12:10:15 +02:00
067e34c19b fix(lcp): revert unoptimized=true on HeroVideo to restore Next.js image optimization for massive CMS images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🧪 QA (push) Successful in 1m47s
Build & Deploy / 🏗️ Build (push) Successful in 3m16s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 42s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-01 12:07:18 +02:00
e6017e7436 perf(images): remove AVIF format to prevent massive CPU spikes and TTFB delays during on-the-fly Next.js image optimization
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🧪 QA (push) Successful in 1m40s
Build & Deploy / 🏗️ Build (push) Successful in 3m7s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m10s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-01 02:24:38 +02:00
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
226 changed files with 90037 additions and 4714 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)
# ────────────────────────────────────────────────────────────────────────────
# 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)
@@ -45,6 +45,7 @@ MAIL_RECIPIENTS=info@e-tib.com
# ────────────────────────────────────────────────────────────────────────────
LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN=
# SENTRY_ENVIRONMENT is set automatically by CI
# ────────────────────────────────────────────────────────────────────────────

View File

@@ -45,6 +45,7 @@ MAIL_RECIPIENTS=info@e-tib.com
# ────────────────────────────────────────────────────────────────────────────
LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN=
# 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
# Error Tracking (GlitchTip/Sentry)
SENTRY_DSN=https://dcb81958dbf24a3db422875f4672c14b@errors.infra.mintel.me/5
SENTRY_DSN=
# Email Configuration (Mailgun)
MAIL_HOST=smtp.eu.mailgun.org

View File

@@ -47,7 +47,14 @@ jobs:
run: |
echo "=== System Disk Usage ==="
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 "=== 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 ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
echo "=== Host Docker Info ==="
@@ -333,9 +340,6 @@ jobs:
{
echo "# Generated by CI - $TARGET"
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 "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
echo "SENTRY_DSN=$SENTRY_DSN"

4
.gitignore vendored
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
FROM node:20-alpine
# 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

View File

@@ -1,6 +1,4 @@
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 { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
@@ -51,34 +49,10 @@ const mdxComponents = {
JsonLd,
Button,
Badge,
Heading,
AnimatedCounter,
GrowthChart,
DeepDrillAnimation,
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
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} />,

View File

@@ -1,6 +1,4 @@
import { notFound, redirect } from 'next/navigation';
import { getImageProps } from 'next/image';
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema';
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} />,
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} />,
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} />,
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} />,
@@ -143,7 +118,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
{/* Title overlay on image */}
<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">
{post.frontmatter.category && (
<div className="overflow-hidden mb-6">
@@ -217,7 +192,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
)}
{/* 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">
{/* Left Column: Content */}
<div className="sticky-narrative-content">

View File

@@ -1,83 +1,13 @@
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) {
try {
const rawText = await req.text();
const items = rawText.split('\n');
if (items.length === 0 || !items[0]) {
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();
});
// Sentry sends NDJSON (Newline Delimited JSON). Split by newline to parse safely.
const items = rawText.split('\n').filter(Boolean).map(line => JSON.parse(line));
console.log("CLIENT ERROR INTERCEPTED:", JSON.stringify(items[0], null, 2));
} catch (e) {
console.error("[Sentry Tunnel] Failed to parse or relay body", e);
return NextResponse.json({ error: 'Relay failed' }, { status: 500 });
console.log("Failed to parse relay body (NDJSON)", e);
}
return NextResponse.json({ success: true });
}

View File

@@ -20,9 +20,8 @@ import { TransitionProvider } from '@/components/providers/TransitionProvider';
import { DynamicInitialLoader as InitialLoader } from '@/components/providers/DynamicInitialLoader';
import { DynamicMobileBottomNav as MobileBottomNav } from '@/components/layout/DynamicMobileBottomNav';
import { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground';
import { DynamicPageTransitionShutter as PageTransitionShutter } from '@/components/providers/DynamicPageTransitionShutter';
import { DynamicFramerMotion as FramerMotionProvider } from '@/components/providers/DynamicFramerMotion';
// Removed CorporateBackground to improve LCP by eliminating Framer Motion main thread block
const corporateBackground = null;
const inter = Inter({
subsets: ['latin'],
@@ -191,28 +190,24 @@ 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>
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<TransitionProvider>
<FramerMotionProvider>
<CorporateBackground />
<InitialLoader shouldShowLoader={!hasSeenLoader} />
<PageTransitionShutter />
<SkipLink />
<Header navLinks={navLinks} />
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
<main
id="main-content"
className="flex-grow overflow-visible"
tabIndex={-1}
>
{children}
</main>
{/* PageTransitionShutter loaded dynamically in TransitionProvider */}
<SkipLink />
<Header navLinks={navLinks} />
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
<main
id="main-content"
className="flex-grow overflow-visible"
tabIndex={-1}
>
{children}
</main>
<Footer companyInfo={companyInfo} />
<JsonLd />
<AnalyticsShell />
<Footer companyInfo={companyInfo} />
<JsonLd />
<AnalyticsShell />
<FeedbackClientWrapper />
</FramerMotionProvider>
<FeedbackClientWrapper />
</TransitionProvider>
</NextIntlClientProvider>
</body>

View File

@@ -132,7 +132,12 @@ export default async function Home(props: { params: Promise<{ locale: string }>
}
setRequestLocale(locale);
preload('/germany-map.svg', { as: 'image', fetchPriority: 'high' });
preload('/assets/videos/web/hero-kabelpflug-poster.webp?v=2', {
as: 'image',
fetchPriority: 'high',
imageSrcSet: '/assets/videos/web/hero-kabelpflug-poster-mobile.webp?v=2 800w, /assets/videos/web/hero-kabelpflug-poster.webp?v=2 1920w',
imageSizes: '100vw'
});
const mdx = await getMdxContent(locale, 'home');
const allReferences = await getAllReferences(locale);

View File

@@ -1,6 +1,4 @@
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 { Metadata } from 'next';
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} />,
li: CustomLi,
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 {

View File

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

326
app/[locale]/team/page.tsx Normal file
View File

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

View File

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

102
app/actions/brochure.ts Normal file
View File

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

View File

@@ -1,145 +0,0 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@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 */
@layer utilities {
.text-balance {
text-wrap: balance;
}
.hide-scrollbar {
-ms-overflow-style: none !important; /* IE and Edge */
scrollbar-width: none !important; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none !important; /* Chrome, Safari and Opera */
width: 0 !important;
height: 0 !important;
}
@keyframes bg-pulse-15 {
0%, 90%, 100% { opacity: 0.04; }
95% { opacity: 0.10; filter: brightness(1.2); }
}
@keyframes bg-pulse-20 {
0%, 90%, 100% { opacity: 0.07; }
95% { opacity: 0.14; filter: brightness(1.2); }
}
.animate-bg-pulse-15 { animation: bg-pulse-15 12s infinite ease-in-out; }
.animate-bg-pulse-20 { animation: bg-pulse-20 12s infinite ease-in-out; }
}
/* Base Styles */
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-neutral font-sans text-neutral-dark antialiased;
}
/* Interactive elements pointer */
a,
button,
[role="button"],
input[type="button"],
input[type="submit"],
input[type="reset"],
select,
summary,
label[for] {
cursor: pointer;
}
/* Ensure disabled elements don't show pointer */
button:disabled,
[role="button"]:disabled,
input:disabled,
select:disabled,
button[disabled] {
cursor: not-allowed;
}
/* Custom Modern Scrollbar */
::-webkit-scrollbar {
width: 14px;
height: 14px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(14, 122, 92, 0.4);
border-radius: 10px;
border: 4px solid transparent;
background-clip: padding-box;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(14, 122, 92, 0.8);
}
/* Firefox */
html {
scrollbar-color: rgba(14, 122, 92, 0.5) transparent;
scrollbar-width: thin;
}
}
/* trigger rebuild */
@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;
}

7
app/test/page.tsx Normal file
View File

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

View File

@@ -81,7 +81,7 @@ export default function ContactForm() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<Heading level={3} align="center" className="mb-4">
<Heading level={3} className="mb-4">
{t('form.successTitle') || 'Message Sent!'}
</Heading>
<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" />
</svg>
</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!'}
</Heading>
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">

View File

@@ -215,9 +215,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<AnimatePresence mode="wait" initial={false}>
<m.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
initial={{ opacity: 0, scale: 1.1 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>

View File

@@ -22,18 +22,11 @@ export default function AnalyticsShell() {
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
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
window.requestIdleCallback(initServices, { timeout: 3000 });
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
} else {
const timer = setTimeout(initServices, 2500);
const timer = setTimeout(() => setShouldLoad(true), 2500);
return () => clearTimeout(timer);
}
}, []);

View File

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

View File

@@ -25,7 +25,7 @@ interface CertificatesBlockProps {
hideHeader?: boolean;
}
export const defaultCertificates: Certificate[] = [
const defaultCertificates: Certificate[] = [
{
title: 'ISO 14001:2015',
description: 'Umweltmanagementsystem',
@@ -47,13 +47,6 @@ export const defaultCertificates: Certificate[] = [
type: 'iso',
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',
description: 'Nach § 48 b EStG',
@@ -75,6 +68,7 @@ export const defaultCertificates: Certificate[] = [
type: 'tax',
date: '13.02.2025',
},
];
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 ? {
href: encodeURI(cert.pdfUrl),
target: "_blank",
rel: "noopener noreferrer",
download: cert.pdfUrl.split('/').pop()
rel: "noopener noreferrer"
} : {};
return (
@@ -208,7 +201,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
</p>
</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' : ''
} ${
isIso ? 'text-primary-light' : 'text-primary'
@@ -216,7 +209,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
{cert.pdfUrl ? (
<>
<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>

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 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="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" />

View File

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

View File

@@ -41,6 +41,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
objectPosition: `${backgroundImage?.focalX ?? 50}% ${backgroundImage?.focalY ?? 50}%`,
}}
sizes="100vw"
quality={50}
priority
/>
{/* Cinematic Color Grading Overlay */}
@@ -60,26 +61,26 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
>
{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}
</Badge>
</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">
{title}
</Heading>
</div>
{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' : ''}`}>
{subtitle}
</p>
</div>
)}
{ctaLabel && ctaHref && (
<div className={`mt-8 animate-hero-fade-in-up ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`} style={{ animationDelay: badge ? '0.55s' : '0.4s', 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
href={ctaHref}
variant="accent"

View File

@@ -1,9 +1,6 @@
'use client';
import { Button } from '@/components/ui/Button';
import Image from 'next/image';
import { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { DeferredVideo } from './DeferredVideo';
import { headers } from 'next/headers';
interface HeroVideoProps {
data?: any;
@@ -30,11 +27,14 @@ export function HeroVideo(props: HeroVideoProps) {
const subtitle = props.subtitle || props.description || data?.subtitle || 'Wir verbinden Infrastruktur mit Präzision. Von Horizontalbohrungen bis zu komplexen Leitungsnetzen.';
const explicitNoVideo = props.videoUrl === 'none' || props.videoUrl === '';
const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4');
const pathname = usePathname();
// In server components, we don't have usePathname, so we can extract it from headers if needed,
// or just use a default fallback for the keys.
const pathname = '/';
let defaultPoster = "/assets/photos/DJI_0048.JPG";
if (videoUrl && videoUrl.endsWith('.mp4')) {
defaultPoster = videoUrl.replace('.mp4', '-poster.jpg');
defaultPoster = videoUrl.replace('.mp4', '-poster.webp?v=2');
}
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || defaultPoster;
@@ -43,71 +43,32 @@ export function HeroVideo(props: HeroVideoProps) {
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
const ctaHref = props.ctaHref || props.linkHref || data?.ctaHref || '#unternehmen';
const currentLocale = pathname?.startsWith('/en') ? 'en' : 'de';
const currentLocale = 'de'; // The layout or page should technically pass the locale, but for now fallback
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`;
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
useEffect(() => {
let interactionTriggered = false;
const handleInteraction = () => {
if (interactionTriggered) return;
interactionTriggered = true;
setVideoSrcLoaded(true);
// Cleanup listeners
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.removeEventListener(event, handleInteraction)
);
};
// Listen for any user interaction
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.addEventListener(event, handleInteraction, { passive: true, once: true })
);
return () => {
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.removeEventListener(event, handleInteraction)
);
};
}, []);
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || 'Projekt anfragen';
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/de/contact`;
return (
<div className="relative w-full h-[85svh] md:h-[100svh] flex items-center justify-center overflow-hidden bg-neutral-dark">
{/* Background color while video loads */}
<div className="absolute inset-0 z-0 bg-neutral-dark" />
{/* ALWAYS render the Next.js optimized Image as the LCP element */}
<Image
<img
key={`img-${pathname}-${posterSrc}`}
src={posterSrc}
srcSet={posterSrc.includes('-poster.webp') ? `${posterSrc.replace('-poster.webp', '-poster-mobile.webp')} 800w, ${posterSrc} 1920w` : undefined}
sizes={posterSrc.includes('-poster.webp') ? "100vw" : undefined}
alt={posterAlt}
fill
className="object-cover z-[1] pointer-events-none"
sizes="100vw"
quality={25}
priority
className="absolute inset-0 w-full h-full object-cover z-[1] pointer-events-none"
decoding="async"
loading="eager"
fetchPriority="high"
/>
{/* Fast compositing layer to replace the heavy CSS filters on the image */}
<div className="absolute inset-0 bg-black/20 z-[1] pointer-events-none mix-blend-multiply" />
{/* Render video on top if available, but defer src to avoid blocking LCP */}
{videoUrl && (
<video
key={`${pathname}-${videoUrl}`}
className={`absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90 transition-opacity duration-1000 ${videoSrcLoaded ? 'opacity-100' : 'opacity-0'}`}
src={videoSrcLoaded ? videoUrl : undefined}
autoPlay
muted
loop
playsInline
preload="none"
/>
)}
<DeferredVideo videoUrl={videoUrl || ''} pathname={pathname} />
{/* Cinematic Color Grading Overlay */}
<div className="absolute inset-0 bg-[#0a192f]/30 z-[2] mix-blend-multiply" />
@@ -119,7 +80,7 @@ export function HeroVideo(props: HeroVideoProps) {
{/* 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="container mx-auto max-w-7xl relative z-[4] text-center px-4">
<div className="container relative z-[4] text-center px-4">
<div
key={`hero-content-${pathname}`}
className="relative"

View File

@@ -53,7 +53,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
showFairs,
fairsTitle = t('fairsTitle'),
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: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
],
@@ -69,7 +69,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
return (
<div className="py-12 md:py-24">
<div className="container max-w-7xl mx-auto">
<div className="container mx-auto">
{showFairs && (
<div className="mb-12 md:mb-24 relative">
<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">
<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>
<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>
@@ -179,7 +179,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
</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
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"

View File

@@ -69,7 +69,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
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">
<div className="container mx-auto max-w-7xl relative z-10 px-4">
<div className="container relative z-10 px-4">
{(badge || title) && (
<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';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { m, AnimatePresence } from 'framer-motion';
import { m } from 'framer-motion';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { LogoArcs } from '@/components/ui/LogoArcs';
export interface TeamMember {
id: string;
@@ -25,105 +22,14 @@ interface TeamGridProps {
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) {
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;
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 (
<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">
<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>
@@ -133,353 +39,75 @@ export function TeamGrid({ members }: TeamGridProps) {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 xl:gap-10">
{members.map((member, i) => {
const imageUrl = member.image ? (typeof member.image === 'string' ? member.image : member.image.url) : null;
const arcsProps = getMemberLogoArcsProps(member.id);
{members.map((member, i) => (
<m.div
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 (
<m.div
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] }}
onMouseEnter={() => handlePreload(imageUrl)}
onTouchStart={() => handlePreload(imageUrl)}
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 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 bg-gradient-to-br from-neutral-100/90 via-neutral-50 to-orange-50/20" />
{/* 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 className="px-8 pb-8 relative flex-grow flex flex-col">
{/* Overlapping Profile Picture */}
<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">
{member.image && (typeof member.image === 'string' ? member.image : member.image.url) ? (
<Image
src={typeof member.image === 'string' ? member.image : member.image.url}
alt={member.name}
fill
sizes="96px"
className="object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-neutral-300 bg-neutral-50">
<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>
</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>
</m.div>
);
})}
</div>
</div>
{/* 3D Carousel Lightbox Modal with Neighbor Blurred Preview Cards & React Portal */}
{mounted && createPortal(
<AnimatePresence>
{selectedMember && (
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedMember(null)}
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"
>
{/* 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>
{/* 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>
)}
{/* Active Main Card with Directional Slide Animation into Center */}
<AnimatePresence custom={direction} mode="popLayout">
<m.div
key={selectedMember.id}
custom={direction}
variants={slideVariants}
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>
{/* Contacts (Sticky at bottom) */}
<div className="mt-auto pt-6 border-t border-neutral-100 flex flex-col gap-4">
{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">
<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>
</m.div>
{/* Contact Info Footer */}
<div className="p-5 sm:p-6 bg-white flex flex-col gap-3">
{selectedMember.email && (
<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-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="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>
<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">
<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>
</div>
</m.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>
<span>{member.phone}</span>
</a>
)}
</div>
</div>
</m.div>
)}
</AnimatePresence>,
document.body
)}
))}
</div>
</div>
</section>
);
}

View File

@@ -23,7 +23,7 @@ export function EUFundingBadge() {
alt="European Union Background"
fill
className="object-cover object-top"
priority
loading="lazy"
/>
</div>

View File

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

View File

@@ -29,7 +29,7 @@ export function Footer({ companyInfo }: FooterProps) {
<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 */}
<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" />
@@ -217,7 +217,7 @@ export function Footer({ companyInfo }: FooterProps) {
</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 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>

View File

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

View File

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

View File

@@ -72,8 +72,8 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
{/* Sweeping intense white light */}
<m.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
initial={{ left: '-150%' }}
animate={{ left: '250%' }}
initial={{ x: '-150%' }}
animate={{ x: '250%' }}
transition={{ duration: 2.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.5 }}
/>
</div>
@@ -91,9 +91,9 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
className="h-[2px] w-full bg-white/5 overflow-hidden relative rounded-full"
>
<m.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary-dark via-primary to-primary-light"
initial={{ width: "0%" }}
animate={{ width: "100%" }}
className="absolute inset-y-0 left-0 w-full bg-gradient-to-r from-primary-dark via-primary to-primary-light origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 3.8, ease: [0.16, 1, 0.3, 1] }}
/>
</m.div>

View File

@@ -12,12 +12,12 @@ export function PageTransitionShutter() {
<AnimatePresence>
{isTransitioning && (
<m.div
key="shutter"
key="page-transition-shutter"
initial={{ y: '-100%' }}
animate={{ y: '0%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
className="fixed left-0 w-full z-[9998] bg-[#050B14] pointer-events-none flex flex-col items-center justify-center overflow-visible"
transition={{ duration: 0.65, ease: [0.76, 0, 0.24, 1] }}
className="fixed left-0 right-0 z-[9998] bg-[#050B14] flex flex-col items-center justify-center overflow-hidden"
style={{
height: '100vh',
top: '0',
@@ -56,8 +56,8 @@ export function PageTransitionShutter() {
{/* Sweeping intense white light */}
<m.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
initial={{ left: '-150%' }}
animate={{ left: '250%' }}
initial={{ x: '-150%' }}
animate={{ x: '250%' }}
transition={{ duration: 1.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.2 }}
/>
</div>

View File

@@ -2,6 +2,16 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { LazyMotion } from 'framer-motion';
import dynamic from 'next/dynamic';
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
// Defer loading the shutter to eliminate AnimatePresence from the initial bundle
const DynamicPageTransitionShutter = dynamic(
() => import('./PageTransitionShutter').then(mod => mod.PageTransitionShutter),
{ ssr: false }
);
interface TransitionContextType {
isTransitioning: boolean;
@@ -14,8 +24,12 @@ const TransitionContext = createContext<TransitionContextType | undefined>(undef
export function TransitionProvider({ children }: { children: ReactNode }) {
const [isTransitioning, setIsTransitioning] = useState(false);
const [transitionMessage, setTransitionMessage] = useState<string | null>(null);
const [hasMountedShutter, setHasMountedShutter] = useState(false);
const router = useRouter();
// We no longer eagerly load the shutter in the background.
// It will be loaded precisely when the user initiates a transition.
// We use a ref to track transition state without triggering the route change effect when it turns true
const isTransitioningRef = React.useRef(isTransitioning);
useEffect(() => {
@@ -41,6 +55,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
setTransitionMessage(message);
setIsTransitioning(true);
setHasMountedShutter(true);
// Die Animation des Shutters abwarten (ca. 700ms), dann pushen
setTimeout(() => {
@@ -68,7 +83,10 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
setTransitionMessage={setTransitionMessage}
/>
</React.Suspense>
{children}
<LazyMotion features={loadFeatures}>
{hasMountedShutter && <DynamicPageTransitionShutter />}
{children}
</LazyMotion>
</TransitionContext.Provider>
);
}

14
compress_images.py Normal file
View File

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

View File

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

View File

@@ -118,7 +118,7 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
items={[
{
title: "Kabelleitungsnetzbau",
description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
description: "Kabelleitungsnetzbau (Nieder-/Mittel- und Hochspannung bis 110 kV)",
tag: "Energie",
size: "large",
href: "/de/kabeltiefbau",

View File

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

View File

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

View File

@@ -20,9 +20,9 @@ layout: "fullBleed"
showFairs={true}
showJobs={false}
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: '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/' }
{ name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }
]}
/>
</div>

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Unternehmen"
title="Unser Team"
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"
/>
@@ -23,14 +23,6 @@ layout: "fullBleed"
phone: "+49 1520 7230518",
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",
name: "Dirk Schmidt",
@@ -55,6 +47,22 @@ layout: "fullBleed"
phone: "+49 171 3868167",
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",
name: "Sven Kirbs",
@@ -63,21 +71,6 @@ layout: "fullBleed"
phone: "+49 151 29603652",
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",
name: "Maik Weichert",

View File

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

View File

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

View File

@@ -20,9 +20,9 @@ layout: "fullBleed"
showFairs={true}
showJobs={false}
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: '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/' }
{ name: 'Werkstatt Kabel 2026', date: 'November 2425, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }
]}
/>
</div>

View File

@@ -9,7 +9,7 @@ layout: "fullBleed"
badge="Company"
title="Our Team"
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"
/>
@@ -23,14 +23,6 @@ layout: "fullBleed"
phone: "+49 1520 7230518",
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",
name: "Dirk Schmidt",
@@ -55,6 +47,22 @@ layout: "fullBleed"
phone: "+49 171 3868167",
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",
name: "Sven Kirbs",
@@ -63,21 +71,6 @@ layout: "fullBleed"
phone: "+49 151 29603652",
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",
name: "Maik Weichert",

View File

@@ -39,13 +39,6 @@ layout: "fullBleed"
type: 'iso',
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',
description: 'According to § 48 b EStG',

View File

@@ -8,7 +8,7 @@ services:
- infra
- default
labels:
- "caddy=http://${TRAEFIK_HOST:-etib.localhost}, http://e-tib.localhost"
- "caddy=http://${TRAEFIK_HOST:-etib.localhost}"
- "caddy.reverse_proxy=http://etib-app:3001"
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -34,9 +34,11 @@ services:
# Force Garbage Collection before Docker kills the container (OOM)
NODE_OPTIONS: "--max-old-space-size=6144"
NEXT_TELEMETRY_DISABLED: "1"
UV_THREADPOOL_SIZE: "1"
RAYON_NUM_THREADS: "1"
NEXT_PRIVATE_WORKER_THREADS: "false"
NPM_TOKEN: ${NPM_TOKEN:-}
CI: "true"
HUSKY: "0"
WATCHPACK_POLLING: "true"
ports:
- "3001:3001"
@@ -46,6 +48,7 @@ services:
- etib_next_cache:/app/.next
- etib_turbo_cache:/app/.turbo
- etib_pnpm_store:/pnpm
- /app/.git
- /app/reference
- /app/data
@@ -54,7 +57,19 @@ services:
limits:
memory: 8G
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:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3001"
@@ -62,7 +77,7 @@ services:
networks:
default:
name: ${COMPOSE_PROJECT_NAME:-etib}-dev-internal
name: ${PROJECT_NAME:-e-tib}-internal
infra:
external: true

View File

@@ -27,7 +27,7 @@ services:
- "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)
- "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.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"

6904
lh-report-prod-2.json Normal file

File diff suppressed because one or more lines are too long

6977
lh-report-prod-en-v2.json Normal file

File diff suppressed because one or more lines are too long

6973
lh-report-prod-en-v3.json Normal file

File diff suppressed because one or more lines are too long

6985
lh-report-prod-en-v4.json Normal file

File diff suppressed because one or more lines are too long

6993
lh-report-prod-en-v5.json Normal file

File diff suppressed because one or more lines are too long

6924
lh-report-prod-en-v7.json Normal file

File diff suppressed because one or more lines are too long

6950
lh-report-prod-en-v8.json Normal file

File diff suppressed because one or more lines are too long

7002
lh-report-prod-en.json Normal file

File diff suppressed because one or more lines are too long

12388
lh-report-prod.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

388
lib/excel-products.ts Normal file
View File

@@ -0,0 +1,388 @@
/**
* Excel Products Module
*
* Provides typed access to technical product data from Excel source files.
* Reuses the parsing logic from the PDF datasheet generator.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as XLSX from 'xlsx';
// Configuration
const EXCEL_SOURCE_FILES = [
path.join(process.cwd(), 'data/excel/high-voltage.xlsx'),
path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/excel/solar-cables.xlsx'),
];
// Types
export type ExcelRow = Record<string, string | number | boolean | Date>;
export interface ExcelMatch {
rows: ExcelRow[];
units: Record<string, string>;
}
export interface TechnicalData {
configurations: string[];
attributes: Array<{
name: string;
options: string[];
}>;
}
export interface ProductLookupParams {
name?: string;
slug?: string;
sku?: string;
translationKey?: string;
}
// Cache singleton
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
/**
* Normalize Excel key to match product identifiers
* Examples:
* - "NA2XS(FL)2Y" -> "NA2XSFL2Y"
* - "na2xsfl2y-3" -> "NA2XSFL2Y"
*/
function normalizeExcelKey(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
/**
* Normalize value (strip HTML, trim whitespace)
*/
function normalizeValue(value: string): string {
if (!value) return '';
return String(value)
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Check if value looks numeric
*/
function looksNumeric(value: string): boolean {
const v = normalizeValue(value).replace(/,/g, '.');
return /^-?\d+(?:\.\d+)?$/.test(v);
}
/**
* Load Excel rows from a file using xlsx library
*/
function loadExcelRows(filePath: string): ExcelRow[] {
if (!fs.existsSync(filePath)) {
console.warn(`[excel-products] File not found: ${filePath}`);
return [];
}
try {
const workbook = XLSX.readFile(filePath, {
cellDates: false,
cellNF: false,
cellText: false
});
// Get the first sheet
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// Convert to JSON
const rows = XLSX.utils.sheet_to_json(worksheet, {
defval: '',
raw: false
}) as ExcelRow[];
return rows;
} catch (error) {
console.error(`[excel-products] Error reading ${filePath}:`, error);
return [];
}
}
/**
* Build the Excel index from all source files
*/
export function getExcelIndex(): Map<string, ExcelMatch> {
if (EXCEL_INDEX) return EXCEL_INDEX;
const idx = new Map<string, ExcelMatch>();
for (const file of EXCEL_SOURCE_FILES) {
const rows = loadExcelRows(file);
if (rows.length === 0) continue;
// Find units row (if present)
const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
const units: Record<string, string> = {};
if (unitsRow) {
for (const [k, v] of Object.entries(unitsRow)) {
if (k === 'Part Number') continue;
const unit = normalizeValue(String(v ?? ''));
if (unit) units[k] = unit;
}
}
// Index rows by Part Number
for (const r of rows) {
const pn = r?.['Part Number'];
if (!pn || pn === 'Units') continue;
const key = normalizeExcelKey(String(pn));
if (!key) continue;
const cur = idx.get(key);
if (!cur) {
idx.set(key, { rows: [r], units });
} else {
cur.rows.push(r);
// Keep the most comprehensive units
if (Object.keys(cur.units).length < Object.keys(units).length) {
cur.units = units;
}
}
}
}
EXCEL_INDEX = idx;
return idx;
}
/**
* Find Excel match for a product using various identifiers
*/
function findExcelForProduct(params: ProductLookupParams): ExcelMatch | null {
const idx = getExcelIndex();
const candidates = [
params.name,
params.slug ? params.slug.replace(/-\d+$/g, '') : '',
params.sku,
params.translationKey,
].filter(Boolean) as string[];
for (const c of candidates) {
const key = normalizeExcelKey(c);
const match = idx.get(key);
if (match && match.rows.length) return match;
}
return null;
}
/**
* Guess column key based on patterns
*/
function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
const keys = Object.keys(row || {});
for (const re of patterns) {
const k = keys.find(x => {
const key = String(x);
// Specific exclusions to prevent wrong matches
if (re.test('conductor') && /ross section conductor/i.test(key)) return false;
if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false;
if (re.test('conductor') && !/^conductor$/i.test(key)) return false;
if (re.test('insulation') && !/^insulation$/i.test(key)) return false;
if (re.test('sheath') && !/^sheath$/i.test(key)) return false;
if (re.test('norm') && !/^norm$/i.test(key)) return false;
return re.test(key);
});
if (k) return k;
}
return null;
}
/**
* Get unique non-empty values from an array
*/
function getUniqueNonEmpty(options: string[]): string[] {
const uniq: string[] = [];
const seen = new Set<string>();
for (const v of options.map(normalizeValue).filter(Boolean)) {
const k = v.toLowerCase();
if (seen.has(k)) continue;
seen.add(k);
uniq.push(v);
}
return uniq;
}
/**
* Get technical data for a product from Excel files
*/
export function getExcelTechnicalDataForProduct(params: ProductLookupParams): TechnicalData | null {
const match = findExcelForProduct(params);
if (!match || match.rows.length === 0) return null;
const rows = match.rows;
const sample = rows[0];
// Find cross-section column
const csKey = guessColumnKey(sample, [
/number of cores and cross-section/i,
/cross.?section/i,
/ross section conductor/i,
]);
if (!csKey) return null;
// Extract configurations
const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]);
const configurations = rows
.map(r => {
const cs = normalizeValue(String(r?.[csKey] ?? ''));
const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : '';
if (!cs) return '';
if (!v) return cs;
const vHasUnit = /\bkv\b/i.test(v);
const vText = vHasUnit ? v : `${v} kV`;
return `${cs} - ${vText}`;
})
.filter(Boolean);
if (configurations.length === 0) return null;
// Extract technical attributes
const attributes: Array<{ name: string; options: string[] }> = [];
// Key technical columns
const outerKey = guessColumnKey(sample, [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]);
const weightKey = guessColumnKey(sample, [/weight\b/i, /gewicht/i, /cable weight/i]);
const dcResKey = guessColumnKey(sample, [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]);
const ratedVoltKey = voltageKey;
const testVoltKey = guessColumnKey(sample, [/test voltage/i, /prüfspannung/i]);
const tempRangeKey = guessColumnKey(sample, [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]);
const conductorKey = guessColumnKey(sample, [/^conductor$/i]);
const insulationKey = guessColumnKey(sample, [/^insulation$/i]);
const sheathKey = guessColumnKey(sample, [/^sheath$/i]);
const normKey = guessColumnKey(sample, [/^norm$/i, /^standard$/i]);
const cprKey = guessColumnKey(sample, [/cpr class/i]);
const packagingKey = guessColumnKey(sample, [/^packaging$/i]);
const shapeKey = guessColumnKey(sample, [/shape of conductor/i]);
const flameKey = guessColumnKey(sample, [/flame retardant/i]);
const diamCondKey = guessColumnKey(sample, [/diameter conductor/i]);
const diamInsKey = guessColumnKey(sample, [/diameter over insulation/i]);
const diamScreenKey = guessColumnKey(sample, [/diameter over screen/i]);
const metalScreenKey = guessColumnKey(sample, [/metallic screen/i]);
const capacitanceKey = guessColumnKey(sample, [/capacitance/i]);
const reactanceKey = guessColumnKey(sample, [/reactance/i]);
const electricalStressKey = guessColumnKey(sample, [/electrical stress/i]);
const pullingForceKey = guessColumnKey(sample, [/max\. pulling force/i, /pulling force/i]);
const heatingTrefoilKey = guessColumnKey(sample, [/heating time constant.*trefoil/i]);
const heatingFlatKey = guessColumnKey(sample, [/heating time constant.*flat/i]);
const currentAirTrefoilKey = guessColumnKey(sample, [/current ratings in air.*trefoil/i]);
const currentAirFlatKey = guessColumnKey(sample, [/current ratings in air.*flat/i]);
const currentGroundTrefoilKey = guessColumnKey(sample, [/current ratings in ground.*trefoil/i]);
const currentGroundFlatKey = guessColumnKey(sample, [/current ratings in ground.*flat/i]);
const scCurrentCondKey = guessColumnKey(sample, [/conductor shortcircuit current/i]);
const scCurrentScreenKey = guessColumnKey(sample, [/screen shortcircuit current/i]);
const minLayKey = guessColumnKey(sample, [/minimal temperature for laying/i]);
const minStoreKey = guessColumnKey(sample, [/minimal storage temperature/i]);
const maxOpKey = guessColumnKey(sample, [/maximal operating conductor temperature/i, /max\. operating/i]);
const maxScKey = guessColumnKey(sample, [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]);
const insThkKey = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]);
const sheathThkKey = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i]);
const maxResKey = guessColumnKey(sample, [/maximum resistance of conductor/i]);
const bendKey = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]);
// Helper to add attribute
const addAttr = (name: string, key: string | null, unit?: string) => {
if (!key) return;
const options = rows
.map(r => normalizeValue(String(r?.[key] ?? '')))
.map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v))
.filter(Boolean);
if (options.length === 0) return;
const uniqueOptions = getUniqueNonEmpty(options);
attributes.push({ name, options: uniqueOptions });
};
// Add attributes
addAttr('Outer diameter', outerKey, 'mm');
addAttr('Weight', weightKey, 'kg/km');
addAttr('DC resistance at 20 °C', dcResKey, 'Ω/km');
addAttr('Rated voltage', ratedVoltKey, '');
addAttr('Test voltage', testVoltKey, '');
addAttr('Operating temperature range', tempRangeKey, '');
addAttr('Minimal temperature for laying', minLayKey, '');
addAttr('Minimal storage temperature', minStoreKey, '');
addAttr('Maximal operating conductor temperature', maxOpKey, '');
addAttr('Maximal short-circuit temperature', maxScKey, '');
addAttr('Nominal insulation thickness', insThkKey, 'mm');
addAttr('Nominal sheath thickness', sheathThkKey, 'mm');
addAttr('Maximum resistance of conductor', maxResKey, 'Ω/km');
addAttr('Conductor', conductorKey, '');
addAttr('Insulation', insulationKey, '');
addAttr('Sheath', sheathKey, '');
addAttr('Standard', normKey, '');
addAttr('Conductor diameter', diamCondKey, 'mm');
addAttr('Insulation diameter', diamInsKey, 'mm');
addAttr('Screen diameter', diamScreenKey, 'mm');
addAttr('Metallic screen', metalScreenKey, '');
addAttr('Max. pulling force', pullingForceKey, '');
addAttr('Electrical stress conductor', electricalStressKey, '');
addAttr('Electrical stress insulation', electricalStressKey, '');
addAttr('Reactance', reactanceKey, '');
addAttr('Heating time constant trefoil', heatingTrefoilKey, 's');
addAttr('Heating time constant flat', heatingFlatKey, 's');
addAttr('Flame retardant', flameKey, '');
addAttr('CPR class', cprKey, '');
addAttr('Packaging', packagingKey, '');
addAttr('Bending radius', bendKey, 'mm');
addAttr('Shape of conductor', shapeKey, '');
addAttr('Capacitance', capacitanceKey, 'μF/km');
addAttr('Current ratings in air, trefoil', currentAirTrefoilKey, 'A');
addAttr('Current ratings in air, flat', currentAirFlatKey, 'A');
addAttr('Current ratings in ground, trefoil', currentGroundTrefoilKey, 'A');
addAttr('Current ratings in ground, flat', currentGroundFlatKey, 'A');
addAttr('Conductor shortcircuit current', scCurrentCondKey, 'kA');
addAttr('Screen shortcircuit current', scCurrentScreenKey, 'kA');
return {
configurations,
attributes,
};
}
/**
* Get raw Excel rows for a product (for detailed inspection)
*/
export function getExcelRowsForProduct(params: ProductLookupParams): ExcelRow[] {
const match = findExcelForProduct(params);
return match?.rows || [];
}
/**
* Clear the Excel index cache (useful for development)
*/
export function clearExcelCache(): void {
EXCEL_INDEX = null;
}
/**
* Preload Excel data on module initialization
* This ensures the cache is built during build time
*/
export function preloadExcelData(): void {
getExcelIndex();
}
// Preload when imported
if (require.main === module) {
preloadExcelData();
}

1436
lib/pdf-brochure.tsx Normal file

File diff suppressed because it is too large Load Diff

329
lib/pdf-page.tsx Normal file
View File

@@ -0,0 +1,329 @@
import * as React from 'react';
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
// Register fonts (using system fonts for now, can be customized)
Font.register({
family: 'Helvetica',
fonts: [
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
],
});
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
const C = {
navy: '#001a4d',
navyDeep: '#000d26',
green: '#4da612',
greenLight: '#e8f5d8',
white: '#FFFFFF',
offWhite: '#f8f9fa',
gray100: '#f3f4f6',
gray200: '#e5e7eb',
gray300: '#d1d5db',
gray400: '#9ca3af',
gray600: '#4b5563',
gray900: '#111827',
};
const MARGIN = 56;
const styles = StyleSheet.create({
page: {
color: C.gray900,
lineHeight: 1.5,
backgroundColor: C.white,
paddingTop: 0,
paddingBottom: 80,
fontFamily: 'Helvetica',
},
// Hero-style header
hero: {
backgroundColor: C.white,
paddingTop: 24,
paddingBottom: 0,
paddingHorizontal: MARGIN,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
logoText: {
fontSize: 22,
fontWeight: 700,
color: C.navyDeep,
letterSpacing: 2,
textTransform: 'uppercase',
},
docTitle: {
fontSize: 8,
fontWeight: 700,
color: C.green,
letterSpacing: 2,
textTransform: 'uppercase',
},
// Content Area
content: {
paddingHorizontal: MARGIN,
},
pageTitle: {
fontSize: 24,
fontWeight: 700,
color: C.navyDeep,
marginBottom: 8,
marginTop: 10,
textTransform: 'uppercase',
letterSpacing: -0.5,
},
accentBar: {
width: 30,
height: 2,
backgroundColor: C.green,
marginBottom: 20,
borderRadius: 1,
},
// Lexical Elements
paragraph: {
fontSize: 10,
color: C.gray600,
lineHeight: 1.7,
marginBottom: 12,
},
heading1: {
fontSize: 16,
fontWeight: 700,
color: C.navyDeep,
marginTop: 20,
marginBottom: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heading2: {
fontSize: 12,
fontWeight: 700,
color: C.navyDeep,
marginTop: 16,
marginBottom: 8,
},
heading3: {
fontSize: 10,
fontWeight: 700,
color: C.navyDeep,
marginTop: 12,
marginBottom: 6,
},
list: {
marginBottom: 12,
marginLeft: 8,
},
listItem: {
flexDirection: 'row',
marginBottom: 4,
},
listItemBullet: {
width: 12,
fontSize: 10,
color: C.green,
fontWeight: 700,
},
listItemContent: {
flex: 1,
fontSize: 10,
color: C.gray600,
lineHeight: 1.7,
},
link: {
color: C.green,
textDecoration: 'none',
},
textBold: {
fontWeight: 700,
fontFamily: 'Helvetica-Bold',
color: C.navyDeep,
},
textItalic: {
fontStyle: 'italic',
},
// Footer — matches brochure style
footer: {
position: 'absolute',
bottom: 28,
left: MARGIN,
right: MARGIN,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: C.green,
},
footerText: {
fontSize: 7,
color: C.gray400,
fontWeight: 400,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
footerBrand: {
fontSize: 9,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1.5,
},
});
// ─── Lexical to React-PDF Renderer ────────────────────────────────
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
if (!node) return null;
switch (node.type) {
case 'text': {
const format = node.format || 0;
const isBold = (format & 1) !== 0;
const isItalic = (format & 2) !== 0;
let elementStyle: any = {};
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
return (
<Text key={idx} style={elementStyle}>
{node.text}
</Text>
);
}
case 'paragraph': {
return (
<Text key={idx} style={styles.paragraph}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
case 'heading': {
let hStyle = styles.heading3;
if (node.tag === 'h1') hStyle = styles.heading1;
if (node.tag === 'h2') hStyle = styles.heading2;
return (
<Text key={idx} style={hStyle}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
case 'list': {
return (
<View key={idx} style={styles.list}>
{node.children?.map((child: any, i: number) => {
if (child.type === 'listitem') {
return (
<View key={i} style={styles.listItem}>
<Text style={styles.listItemBullet}>
{node.listType === 'number' ? `${i + 1}.` : '•'}
</Text>
<Text style={styles.listItemContent}>
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
</Text>
</View>
);
}
return renderLexicalNode(child, i);
})}
</View>
);
}
case 'link': {
const href = node.fields?.url || node.url || '#';
return (
<Link key={idx} src={href} style={styles.link}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Link>
);
}
case 'linebreak': {
return <Text key={idx}>{'\n'}</Text>;
}
// Ignore payload blocks recursively to avoid crashing
case 'block':
return null;
default:
if (node.children) {
return (
<Text key={idx}>
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
return null;
}
};
interface PDFPageProps {
page: any;
locale?: string;
}
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Hero Header */}
<View style={styles.hero} fixed>
<View style={styles.header}>
<View>
<Text style={styles.logoText}>KLZ</Text>
</View>
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
</View>
</View>
<View style={styles.content}>
<Text style={styles.pageTitle}>{page.title}</Text>
<View style={styles.accentBar} />
<View>
{page.content?.root?.children?.map((node: any, i: number) =>
renderLexicalNode(node, i),
)}
</View>
</View>
{/* Minimal footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>{dateStr}</Text>
</View>
</Page>
</Document>
);
};

View File

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

View File

@@ -2,7 +2,6 @@ import { AppServices } from './app-services';
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
import { UmamiAnalyticsService } from './analytics/umami-analytics-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 { NoopLoggerService } from './logging/noop-logger-service';
import { PinoLoggerService } from './logging/pino-logger-service';
@@ -68,17 +67,7 @@ export function getAppServices(): AppServices {
logger.info('Notification service initialized (noop)');
// Create error reporting service (GlitchTip/Sentry or no-op)
const errors = sentryEnabled
? new GlitchtipErrorReportingService(
{
enabled: true,
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 0.1, // Client-side we usually want lower sample rate
},
logger,
notifications,
)
: new NoopErrorReportingService();
const errors = new NoopErrorReportingService();
if (sentryEnabled) {
logger.info(

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -66,6 +66,10 @@
"role": "Geschäftsführer",
"description": "Danny Joseph leitet die E-TIB GmbH und koordiniert die strategische Ausrichtung der gesamten Unternehmensgruppe vom Hauptsitz in Guben aus."
},
"gallery": {
"title": "Einblicke in unsere Arbeit",
"subtitle": "Eindrücke aus dem Arbeitsalltag und Projekten"
},
"groups": {
"title": "Unsere Unternehmensgruppe",
"subtitle": "Spezialisierte Einheiten für komplexe Projekte",
@@ -280,7 +284,7 @@
"TeamGrid": {
"badge": "Persönliche Beratung",
"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",
"branchETIB": "E-TIB GmbH",
"branchIng": "Ingenieurgesellschaft",

View File

@@ -63,8 +63,12 @@
},
"danny": {
"name": "Danny Joseph",
"role": "CEO",
"description": "Danny Joseph leads E-TIB GmbH and coordinates the strategic direction of the entire group from its headquarters in Guben."
"role": "Managing Director",
"description": "Danny Joseph leads E-TIB GmbH and coordinates the strategic direction of the entire corporate group from the headquarters in Guben."
},
"gallery": {
"title": "Insights into our work",
"subtitle": "Impressions from daily work and projects"
},
"groups": {
"title": "Our Corporate Group",
@@ -280,7 +284,7 @@
"TeamGrid": {
"badge": "Personal Consultation",
"title": "Your Contacts",
"subtitle": "Speak directly with our experts for your project.",
"subtitle": "Speak directly with our experts for your regional project.",
"management": "Management",
"branchETIB": "E-TIB GmbH",
"branchIng": "Engineering Company",

View File

@@ -15,7 +15,7 @@ const nextConfig = {
pagesBufferLength: 2,
},
experimental: {
inlineCss: true,
inlineCss: false,
staleTimes: {
dynamic: 0,
static: 30,

View File

@@ -1,6 +1,6 @@
{
"name": "e-tib-nextjs",
"version": "2.4.55",
"version": "2.4.17",
"type": "module",
"private": true,
"packageManager": "pnpm@10.18.3",
@@ -12,6 +12,7 @@
"@mintel/next-utils": "1.9.5",
"@next/mdx": "^16.1.6",
"@react-email/components": "1.0.8",
"@react-pdf/renderer": "^4.5.1",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@sentry/nextjs": "^10.39.0",
@@ -73,7 +74,6 @@
"@types/sharp": "^0.31.1",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react-swc": "^4.3.1",
"@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23",
"cheerio": "^1.2.0",
@@ -101,7 +101,7 @@
"xlsx-cli": "^1.1.3"
},
"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:infra": "COMPOSE_PROJECT_NAME=etib docker compose -f docker-compose.dev.yml up -d etib-proxy",
"build": "next build",
@@ -141,7 +141,6 @@
"prepare": "husky",
"preinstall": "npx only-allow pnpm"
},
"version": "2.4.48",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",

300
pnpm-lock.yaml generated
View File

@@ -34,6 +34,9 @@ importers:
'@react-email/components':
specifier: 1.0.8
version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-pdf/renderer':
specifier: ^4.5.1
version: 4.5.1(react@19.2.4)
'@react-three/drei':
specifier: ^10.7.7
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)
@@ -212,9 +215,6 @@ importers:
'@vitejs/plugin-react':
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))
'@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':
specifier: ^4.0.16
version: 4.1.2(vitest@4.1.2)
@@ -2213,6 +2213,49 @@ packages:
react: ^18.0.0
react-dom: ^18.0.0
'@react-pdf/fns@3.1.3':
resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==}
'@react-pdf/font@4.0.8':
resolution: {integrity: sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==}
'@react-pdf/image@3.1.0':
resolution: {integrity: sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==}
'@react-pdf/layout@4.6.1':
resolution: {integrity: sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==}
'@react-pdf/pdfkit@5.1.1':
resolution: {integrity: sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==}
'@react-pdf/primitives@4.3.0':
resolution: {integrity: sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==}
'@react-pdf/reconciler@2.0.0':
resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-pdf/render@4.5.1':
resolution: {integrity: sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==}
'@react-pdf/renderer@4.5.1':
resolution: {integrity: sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-pdf/stylesheet@6.2.1':
resolution: {integrity: sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==}
'@react-pdf/svg@1.1.0':
resolution: {integrity: sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==}
'@react-pdf/textkit@6.3.0':
resolution: {integrity: sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==}
'@react-pdf/types@2.11.1':
resolution: {integrity: sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==}
'@react-three/drei@10.7.7':
resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==}
peerDependencies:
@@ -2355,9 +2398,6 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.3':
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rollup/plugin-commonjs@28.0.1':
resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==}
engines: {node: '>=16.0.0 || 14 >= 14.17'}
@@ -3292,12 +3332,6 @@ packages:
peerDependencies:
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':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3396,6 +3430,9 @@ packages:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
abs-svg-path@0.1.1:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -3731,6 +3768,9 @@ packages:
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
browserify-zlib@0.2.0:
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -3925,6 +3965,14 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-name@2.1.0:
resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==}
engines: {node: '>=12.20'}
color-string@2.1.4:
resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==}
engines: {node: '>=18'}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -4376,6 +4424,9 @@ packages:
electron-to-chromium@1.5.330:
resolution: {integrity: sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==}
emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -5099,6 +5150,12 @@ packages:
resolution: {integrity: sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==}
engines: {node: '>= 6.0.0'}
hsl-to-hex@1.0.0:
resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
hsl-to-rgb-for-reals@1.1.1:
resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -5180,6 +5237,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
hyphen@1.14.1:
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
i18next-fs-backend@2.6.3:
resolution: {integrity: sha512-/5aW996nbolGh/qVU9urjAJ2QiXpiyGuhG8x/ZvvZQ3bMlrK+ouUdD6whS4e+sU9CCdUIzl7x/heEjsBh/3xtw==}
@@ -5471,6 +5531,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -5512,6 +5575,9 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jay-peg@1.1.1:
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
jest-worker@27.5.1:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
@@ -5945,6 +6011,9 @@ packages:
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
media-engine@1.0.3:
resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -6285,6 +6354,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize-svg-path@1.1.0:
resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -6431,6 +6503,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -6449,6 +6524,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-svg-path@0.1.2:
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
@@ -6572,6 +6650,9 @@ packages:
png-js@1.0.0:
resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==}
png-js@2.0.0:
resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==}
po-parser@2.1.1:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
@@ -6716,6 +6797,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@@ -7009,6 +7093,9 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.25.0-rc-603e6108-20241029:
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -7282,6 +7369,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -7367,6 +7457,9 @@ packages:
peerDependencies:
react: '>=17.0'
svg-arc-to-cubic-bezier@3.2.0:
resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
svg-to-pdfkit@0.1.8:
resolution: {integrity: sha512-QItiGZBy5TstGy+q8mjQTMGRlDDOARXLxH+sgVm1n/LYeo0zFcQlcCh8m4zi8QxctrxB9Kue/lStc/RD5iLadQ==}
@@ -7750,6 +7843,10 @@ packages:
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-compatible-readable-stream@3.6.1:
resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
engines: {node: '>= 6'}
vite@8.0.3:
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -8088,6 +8185,9 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -10010,6 +10110,110 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@react-pdf/fns@3.1.3': {}
'@react-pdf/font@4.0.8':
dependencies:
'@react-pdf/pdfkit': 5.1.1
'@react-pdf/types': 2.11.1
fontkit: 2.0.4
is-url: 1.2.4
'@react-pdf/image@3.1.0':
dependencies:
'@react-pdf/svg': 1.1.0
jay-peg: 1.1.1
png-js: 2.0.0
'@react-pdf/layout@4.6.1':
dependencies:
'@react-pdf/fns': 3.1.3
'@react-pdf/image': 3.1.0
'@react-pdf/primitives': 4.3.0
'@react-pdf/stylesheet': 6.2.1
'@react-pdf/textkit': 6.3.0
'@react-pdf/types': 2.11.1
emoji-regex-xs: 1.0.0
queue: 6.0.2
yoga-layout: 3.2.1
'@react-pdf/pdfkit@5.1.1':
dependencies:
'@babel/runtime': 7.29.2
'@noble/ciphers': 1.3.0
'@noble/hashes': 1.8.0
browserify-zlib: 0.2.0
fontkit: 2.0.4
jay-peg: 1.1.1
js-md5: 0.8.3
linebreak: 1.1.0
png-js: 2.0.0
vite-compatible-readable-stream: 3.6.1
'@react-pdf/primitives@4.3.0': {}
'@react-pdf/reconciler@2.0.0(react@19.2.4)':
dependencies:
object-assign: 4.1.1
react: 19.2.4
scheduler: 0.25.0-rc-603e6108-20241029
'@react-pdf/render@4.5.1':
dependencies:
'@babel/runtime': 7.29.2
'@react-pdf/fns': 3.1.3
'@react-pdf/primitives': 4.3.0
'@react-pdf/textkit': 6.3.0
'@react-pdf/types': 2.11.1
abs-svg-path: 0.1.1
color-string: 2.1.4
normalize-svg-path: 1.1.0
parse-svg-path: 0.1.2
svg-arc-to-cubic-bezier: 3.2.0
'@react-pdf/renderer@4.5.1(react@19.2.4)':
dependencies:
'@babel/runtime': 7.29.2
'@react-pdf/fns': 3.1.3
'@react-pdf/font': 4.0.8
'@react-pdf/layout': 4.6.1
'@react-pdf/pdfkit': 5.1.1
'@react-pdf/primitives': 4.3.0
'@react-pdf/reconciler': 2.0.0(react@19.2.4)
'@react-pdf/render': 4.5.1
'@react-pdf/types': 2.11.1
events: 3.3.0
object-assign: 4.1.1
prop-types: 15.8.1
queue: 6.0.2
react: 19.2.4
'@react-pdf/stylesheet@6.2.1':
dependencies:
'@react-pdf/fns': 3.1.3
'@react-pdf/types': 2.11.1
color-string: 2.1.4
hsl-to-hex: 1.0.0
media-engine: 1.0.3
postcss-value-parser: 4.2.0
'@react-pdf/svg@1.1.0':
dependencies:
'@react-pdf/primitives': 4.3.0
'@react-pdf/textkit@6.3.0':
dependencies:
'@react-pdf/fns': 3.1.3
bidi-js: 1.0.3
hyphen: 1.14.1
unicode-properties: 1.4.1
'@react-pdf/types@2.11.1':
dependencies:
'@react-pdf/font': 4.0.8
'@react-pdf/primitives': 4.3.0
'@react-pdf/stylesheet': 6.2.1
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)':
dependencies:
'@babel/runtime': 7.29.2
@@ -10129,8 +10333,6 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.3': {}
'@rolldown/pluginutils@1.0.1': {}
'@rollup/plugin-commonjs@28.0.1(rollup@4.60.1)':
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
@@ -11042,14 +11244,6 @@ snapshots:
'@use-gesture/core': 10.3.1
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))':
dependencies:
'@babel/core': 7.29.0
@@ -11198,6 +11392,8 @@ snapshots:
abbrev@2.0.0: {}
abs-svg-path@0.1.1: {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -11518,6 +11714,10 @@ snapshots:
dependencies:
base64-js: 1.5.1
browserify-zlib@0.2.0:
dependencies:
pako: 1.0.11
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.13
@@ -11732,6 +11932,12 @@ snapshots:
color-name@1.1.4: {}
color-name@2.1.0: {}
color-string@2.1.4:
dependencies:
color-name: 2.1.0
colorette@2.0.20: {}
combined-stream@1.0.8:
@@ -12209,6 +12415,8 @@ snapshots:
electron-to-chromium@1.5.330: {}
emoji-regex-xs@1.0.0: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -13261,6 +13469,12 @@ snapshots:
hoopy@0.1.4: {}
hsl-to-hex@1.0.0:
dependencies:
hsl-to-rgb-for-reals: 1.1.1
hsl-to-rgb-for-reals@1.1.1: {}
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
dependencies:
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
@@ -13359,6 +13573,8 @@ snapshots:
husky@9.1.7: {}
hyphen@1.14.1: {}
i18next-fs-backend@2.6.3: {}
i18next@25.10.10(typescript@5.9.3):
@@ -13647,6 +13863,8 @@ snapshots:
is-unicode-supported@2.1.0: {}
is-url@1.2.4: {}
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -13697,6 +13915,10 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jay-peg@1.1.1:
dependencies:
restructure: 3.0.2
jest-worker@27.5.1:
dependencies:
'@types/node': 22.19.15
@@ -14245,6 +14467,8 @@ snapshots:
mdn-data@2.27.1: {}
media-engine@1.0.3: {}
media-typer@0.3.0: {}
meow@13.2.0: {}
@@ -14705,6 +14929,10 @@ snapshots:
normalize-path@3.0.0: {}
normalize-svg-path@1.1.0:
dependencies:
svg-arc-to-cubic-bezier: 3.2.0
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@@ -14915,6 +15143,8 @@ snapshots:
pako@0.2.9: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -14942,6 +15172,8 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-svg-path@0.1.2: {}
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
@@ -15084,6 +15316,10 @@ snapshots:
png-js@1.0.0: {}
png-js@2.0.0:
dependencies:
fflate: 0.8.2
po-parser@2.1.1: {}
possible-typed-array-names@1.1.0: {}
@@ -15241,6 +15477,10 @@ snapshots:
queue-microtask@1.2.3: {}
queue@6.0.2:
dependencies:
inherits: 2.0.4
quick-format-unescaped@4.0.4: {}
range-parser@1.2.1: {}
@@ -15659,6 +15899,8 @@ snapshots:
dependencies:
xmlchars: 2.2.0
scheduler@0.25.0-rc-603e6108-20241029: {}
scheduler@0.27.0: {}
schema-dts@1.1.5: {}
@@ -16047,6 +16289,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -16117,6 +16363,8 @@ snapshots:
dependencies:
react: 19.2.4
svg-arc-to-cubic-bezier@3.2.0: {}
svg-to-pdfkit@0.1.8:
dependencies:
pdfkit: 0.18.0
@@ -16585,6 +16833,12 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-compatible-readable-stream@3.6.1:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
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:
lightningcss: 1.32.0
@@ -16916,6 +17170,8 @@ snapshots:
yoctocolors@2.1.2: {}
yoga-layout@3.2.1: {}
zod-validation-error@4.0.2(zod@3.25.76):
dependencies:
zod: 3.25.76

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 94 KiB

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