Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a9f414e87 | |||
| 652bd92321 | |||
| 3ea8f21294 | |||
| 3a216b3961 | |||
| 38ff51d95c | |||
| 616a3a5377 | |||
| ea9135af56 | |||
| aaec77598e | |||
| 6b2dad26ec | |||
| 606fdf9a9f | |||
| 22e579761a | |||
| 7762b1e23b | |||
| 8c2e9bb11d | |||
| a5c997330c | |||
| 063d0fb4db | |||
| 9e70379aba | |||
| 68be6ff8dd | |||
| 2dc53ae002 | |||
| 5b26b6f986 | |||
| a651406014 | |||
| b5718f9420 | |||
| 17592063bb | |||
| a5776f24d7 | |||
| 19ccd90070 | |||
| c1cad7b99b | |||
| cbda41ee95 | |||
| 42ccafd445 | |||
| 84402f90fc | |||
| a701495607 | |||
| 88132b8f84 | |||
| 7a9a21732b | |||
| f75bc146c5 | |||
| 0f554f2317 | |||
| 51a8e63d63 | |||
| 0f95f09711 | |||
| 0ed28915cc | |||
| b8c21ca4b3 | |||
| 4d4185b1f3 | |||
| 59b8e24bcf | |||
| adf2851504 | |||
| e398fbceed | |||
| 850d9e392e | |||
| 8f86575471 | |||
| 97467cbda9 | |||
| 076aa5c13d | |||
| a010a63281 | |||
| 9880a3f8dd | |||
| 531e1a49f8 | |||
| 324cfb0ca3 | |||
| 69d2c42ef7 | |||
| 6fb46278e0 | |||
| 73886406a4 | |||
| b2701d6bcd | |||
| 4f46825fb4 | |||
| c63766711d | |||
| c738001f9a | |||
| 69dd79dfad | |||
| a6c4cc53f9 | |||
| 29f83f8151 | |||
| cec08e3ab3 | |||
| 837567c1f4 | |||
| 4dcc0061fc | |||
| bd25ec935d | |||
| b3b85202eb | |||
| b4b0c7c48a | |||
| 35fb37bb14 | |||
| fb2d385e88 | |||
| c54085fe3f | |||
| 232c55c566 | |||
| 52d81ea6cc | |||
| bc0e7c2283 | |||
| 6d2152998b | |||
| f727e2abfb | |||
| f04163f56d | |||
| e9e209d6e7 | |||
| 9af6a53325 | |||
| 8627bae98b | |||
| 17620482da | |||
| c5e9a67546 | |||
| 1b7e42ccc9 | |||
| 7a2382606e | |||
| 3b16dbe08d | |||
| 09d0395233 | |||
| 857c055862 | |||
| 500c2c64f4 | |||
| e13ce8bcec | |||
| feb8372a7c | |||
| c190a4f67e | |||
| 6717b32d44 | |||
| f1732917bc | |||
| 1f666d1b34 | |||
| ab42590206 | |||
| 7d4ff03806 | |||
| 26ed3e3f1f | |||
| eaf40dec7c | |||
| d56ce045c3 | |||
| 586b6cdee8 | |||
| f1b8ee1188 | |||
| 88cc214ddb | |||
| 25facb995f | |||
| 7b01924055 | |||
| 474672b2a1 | |||
| 7e552bbea5 | |||
| 21886a693c | |||
| 45944cbfa7 | |||
| 761c9c956f | |||
| e18559b83c | |||
| 818ccc8fae | |||
| b64b2ccf25 | |||
| e219b3b818 | |||
| 88628ee663 | |||
| 734dd19725 | |||
| 3175b99177 | |||
| 1986280520 | |||
| d4437f5274 | |||
| f09c338d0d | |||
| 91d6073a58 | |||
| c5f2642d05 |
9
.agents/AGENTS.md
Normal file
9
.agents/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<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
3
.env
@@ -27,7 +27,7 @@ UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
|||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Optional: Leave empty to disable error tracking
|
# Optional: Leave empty to disable error tracking
|
||||||
SENTRY_DSN=https://dcb81958-dbf2-4a3d-b422-875f4672c14b@glitchtip.infra.mintel.me/5
|
SENTRY_DSN=https://dcb81958dbf24a3db422875f4672c14b@errors.infra.mintel.me/5
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Email Configuration (SMTP)
|
# Email Configuration (SMTP)
|
||||||
@@ -45,7 +45,6 @@ MAIL_RECIPIENTS=info@e-tib.com
|
|||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
SENTRY_DSN=
|
|
||||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ MAIL_RECIPIENTS=info@e-tib.com
|
|||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
GATEKEEPER_PASSWORD=klz2026
|
||||||
SENTRY_DSN=
|
|
||||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ UMAMI_WEBSITE_ID=d773ea10-a3b3-4ccf-9024-987e14c4d669
|
|||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=https://dcb81958dbf24a3db422875f4672c14b@errors.infra.mintel.me/5
|
||||||
|
|
||||||
# Email Configuration (Mailgun)
|
# Email Configuration (Mailgun)
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ concurrency:
|
|||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||||
|
GIT_HTTP_VERSION: HTTP/1.1
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -46,16 +47,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "=== System Disk Usage ==="
|
echo "=== System Disk Usage ==="
|
||||||
df -h || true
|
df -h || true
|
||||||
echo "=== Tool Cache Usage ==="
|
echo "Purging old tool caches skipped to prevent runner corruption."
|
||||||
du -sh /opt/hostedtoolcache/* || true
|
|
||||||
du -sh /opt/hostedtoolcache/.[!.]* || true
|
|
||||||
echo "Purging old tool caches..."
|
|
||||||
rm -rf /opt/hostedtoolcache/* || true
|
|
||||||
rm -rf /opt/hostedtoolcache/.[!.]* || true
|
|
||||||
echo "=== Host Disk Usage ==="
|
|
||||||
docker run --rm -v /:/host-root alpine df -h || true
|
|
||||||
echo "=== Host Inode Usage ==="
|
|
||||||
docker run --rm -v /:/host-root alpine df -i || true
|
|
||||||
echo "=== Running Host Containers ==="
|
echo "=== Running Host Containers ==="
|
||||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
|
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
|
||||||
echo "=== Host Docker Info ==="
|
echo "=== Host Docker Info ==="
|
||||||
@@ -96,6 +88,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
TARGET="branch"
|
TARGET="branch"
|
||||||
|
|
||||||
|
# Check if this exact commit has a tag. If so, skip the branch deploy
|
||||||
|
# because the tag pipeline will handle the production/staging deploy.
|
||||||
|
git fetch --tags --quiet || true
|
||||||
|
if git describe --tags --exact-match HEAD >/dev/null 2>&1; then
|
||||||
|
echo "Commit has a tag. Skipping branch deployment to favor tag deployment."
|
||||||
|
TARGET="skip"
|
||||||
|
fi
|
||||||
|
|
||||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.branch-${SLUG}"
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
@@ -202,6 +203,13 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🚦 Priority Gate Check
|
||||||
|
env:
|
||||||
|
GITEA_PAT: ${{ secrets.GITEA_PAT }}
|
||||||
|
CURRENT_RUN_ID: ${{ github.run_id }}
|
||||||
|
CURRENT_TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
|
run: bash scripts/priority-gate.sh
|
||||||
|
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
id: auth
|
id: auth
|
||||||
env:
|
env:
|
||||||
@@ -281,6 +289,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🚦 Priority Gate Check
|
||||||
|
env:
|
||||||
|
GITEA_PAT: ${{ secrets.GITEA_PAT }}
|
||||||
|
CURRENT_RUN_ID: ${{ github.run_id }}
|
||||||
|
CURRENT_TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
|
run: bash scripts/priority-gate.sh
|
||||||
- name: 📝 Generate Environment
|
- name: 📝 Generate Environment
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -318,6 +333,9 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "# Generated by CI - $TARGET"
|
echo "# Generated by CI - $TARGET"
|
||||||
echo "IMAGE_TAG=$IMAGE_TAG"
|
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||||
|
if [[ "$TARGET" != "production" ]]; then
|
||||||
|
echo "NODE_TLS_REJECT_UNAUTHORIZED=0"
|
||||||
|
fi
|
||||||
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||||
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||||
echo "SENTRY_DSN=$SENTRY_DSN"
|
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"chromeFlags":"--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --headless=new","port":59457}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,6 @@
|
|||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import { getImageProps } from 'next/image';
|
||||||
|
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
|
||||||
import { Container, Badge, Heading } from '@/components/ui';
|
import { Container, Badge, Heading } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -49,10 +51,34 @@ const mdxComponents = {
|
|||||||
JsonLd,
|
JsonLd,
|
||||||
Button,
|
Button,
|
||||||
Badge,
|
Badge,
|
||||||
|
Heading,
|
||||||
AnimatedCounter,
|
AnimatedCounter,
|
||||||
GrowthChart,
|
GrowthChart,
|
||||||
DeepDrillAnimation,
|
DeepDrillAnimation,
|
||||||
DataGridPulse,
|
DataGridPulse,
|
||||||
|
ScaleOfImpact,
|
||||||
|
ResponsiveImage: (props: any) => {
|
||||||
|
let src = props.src;
|
||||||
|
if (typeof src === 'string' && src.startsWith('/_next/image')) {
|
||||||
|
try {
|
||||||
|
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
|
||||||
|
if (urlParam) src = decodeURIComponent(urlParam);
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore invalid URL parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { props: { srcSet, src: finalSrc, sizes } } = getImageProps({
|
||||||
|
src,
|
||||||
|
alt: props.alt || '',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
|
||||||
|
quality: 80,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<img {...props} src={finalSrc} srcSet={srcSet} sizes={sizes} loading="lazy" decoding="async" />
|
||||||
|
);
|
||||||
|
},
|
||||||
// Standard HTML element mapping for consistent E-TIB typography
|
// Standard HTML element mapping for consistent E-TIB typography
|
||||||
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
|
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
|
||||||
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
|
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import { getImageProps } from 'next/image';
|
||||||
|
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,29 @@ const mdxComponents = {
|
|||||||
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
|
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
|
||||||
h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />,
|
h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />,
|
||||||
h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />,
|
h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />,
|
||||||
|
ScaleOfImpact,
|
||||||
|
ResponsiveImage: (props: any) => {
|
||||||
|
let src = props.src;
|
||||||
|
if (typeof src === 'string' && src.startsWith('/_next/image')) {
|
||||||
|
try {
|
||||||
|
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
|
||||||
|
if (urlParam) src = decodeURIComponent(urlParam);
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore invalid URL parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { props: { srcSet, src: finalSrc, sizes } } = getImageProps({
|
||||||
|
src,
|
||||||
|
alt: props.alt || '',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
|
||||||
|
quality: 80,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<img {...props} src={finalSrc} srcSet={srcSet} sizes={sizes} loading="lazy" decoding="async" />
|
||||||
|
);
|
||||||
|
},
|
||||||
p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-normal max-w-3xl" {...props} />,
|
p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-normal max-w-3xl" {...props} />,
|
||||||
ul: (props: any) => <ul className="list-none mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
|
ul: (props: any) => <ul className="list-none mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
|
||||||
ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
|
ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
|
||||||
@@ -118,7 +143,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto max-w-7xl px-4">
|
||||||
<div className="max-w-4xl bg-neutral-dark/40 backdrop-blur-md p-6 rounded-2xl border border-white/10">
|
<div className="max-w-4xl bg-neutral-dark/40 backdrop-blur-md p-6 rounded-2xl border border-white/10">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<div className="overflow-hidden mb-6">
|
<div className="overflow-hidden mb-6">
|
||||||
@@ -192,7 +217,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content Area with Sticky Narrative Layout */}
|
{/* Main Content Area with Sticky Narrative Layout */}
|
||||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
<div className="container mx-auto max-w-7xl px-4 py-16 md:py-24">
|
||||||
<div className="sticky-narrative-container">
|
<div className="sticky-narrative-container">
|
||||||
{/* Left Column: Content */}
|
{/* Left Column: Content */}
|
||||||
<div className="sticky-narrative-content">
|
<div className="sticky-narrative-content">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { getAppServices } from '@/lib/services/create-services';
|
|
||||||
import { Button, Heading } from '@/components/ui';
|
import { Button, Heading } from '@/components/ui';
|
||||||
import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react';
|
import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react';
|
||||||
import { m } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
@@ -26,9 +25,7 @@ export default function Error({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const services = getAppServices();
|
console.error('Application error caught by boundary', {
|
||||||
services.errors.captureException(error);
|
|
||||||
services.logger.error('Application error caught by boundary', {
|
|
||||||
message: error?.message || 'Unknown error',
|
message: error?.message || 'Unknown error',
|
||||||
stack: error?.stack,
|
stack: error?.stack,
|
||||||
digest: error?.digest,
|
digest: error?.digest,
|
||||||
|
|||||||
@@ -1,13 +1,83 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
const ALLOWED_HOSTS = ['glitchtip.infra.mintel.me', 'errors.infra.mintel.me'];
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const rawText = await req.text();
|
const rawText = await req.text();
|
||||||
// Sentry sends NDJSON (Newline Delimited JSON). Split by newline to parse safely.
|
const items = rawText.split('\n');
|
||||||
const items = rawText.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
||||||
console.log("CLIENT ERROR INTERCEPTED:", JSON.stringify(items[0], null, 2));
|
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();
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Failed to parse relay body (NDJSON)", e);
|
console.error("[Sentry Tunnel] Failed to parse or relay body", e);
|
||||||
|
return NextResponse.json({ error: 'Relay failed' }, { status: 500 });
|
||||||
}
|
}
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Footer } from '@/components/layout/Footer';
|
import { Footer } from '@/components/layout/Footer';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { MobileBottomNav } from '@/components/layout/MobileBottomNav';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import SkipLink from '@/components/SkipLink';
|
import SkipLink from '@/components/SkipLink';
|
||||||
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||||
@@ -17,9 +16,13 @@ import { mapFileSlugToTranslated } from '@/lib/slugs';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
import { TransitionProvider } from '@/components/providers/TransitionProvider';
|
import { TransitionProvider } from '@/components/providers/TransitionProvider';
|
||||||
import { InitialLoader } from '@/components/providers/InitialLoader';
|
|
||||||
|
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 { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground';
|
||||||
import { DynamicPageTransitionShutter as PageTransitionShutter } from '@/components/providers/DynamicPageTransitionShutter';
|
import { DynamicPageTransitionShutter as PageTransitionShutter } from '@/components/providers/DynamicPageTransitionShutter';
|
||||||
|
import { DynamicFramerMotion as FramerMotionProvider } from '@/components/providers/DynamicFramerMotion';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
@@ -188,26 +191,28 @@ export default async function Layout(props: {
|
|||||||
<body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white" suppressHydrationWarning>
|
<body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white" suppressHydrationWarning>
|
||||||
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||||
<TransitionProvider>
|
<TransitionProvider>
|
||||||
<CorporateBackground />
|
<FramerMotionProvider>
|
||||||
<InitialLoader shouldShowLoader={!hasSeenLoader} />
|
<CorporateBackground />
|
||||||
<PageTransitionShutter />
|
<InitialLoader shouldShowLoader={!hasSeenLoader} />
|
||||||
<SkipLink />
|
<PageTransitionShutter />
|
||||||
<Header navLinks={navLinks} />
|
<SkipLink />
|
||||||
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
|
<Header navLinks={navLinks} />
|
||||||
|
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
|
||||||
<main
|
|
||||||
id="main-content"
|
<main
|
||||||
className="flex-grow animate-fade-in overflow-visible"
|
id="main-content"
|
||||||
tabIndex={-1}
|
className="flex-grow overflow-visible"
|
||||||
>
|
tabIndex={-1}
|
||||||
{children}
|
>
|
||||||
</main>
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
<Footer companyInfo={companyInfo} />
|
<Footer companyInfo={companyInfo} />
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<AnalyticsShell />
|
<AnalyticsShell />
|
||||||
|
|
||||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
<FeedbackClientWrapper />
|
||||||
|
</FramerMotionProvider>
|
||||||
</TransitionProvider>
|
</TransitionProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ export async function generateMetadata(props: any): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Home(props: any) {
|
import { preload } from 'react-dom';
|
||||||
|
|
||||||
|
export default async function Home(props: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await props.params;
|
const { locale } = await props.params;
|
||||||
|
|
||||||
if (locale !== 'de' && locale !== 'en') {
|
if (locale !== 'de' && locale !== 'en') {
|
||||||
@@ -130,6 +132,7 @@ export default async function Home(props: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
|
preload('/germany-map.svg', { as: 'image', fetchPriority: 'high' });
|
||||||
|
|
||||||
const mdx = await getMdxContent(locale, 'home');
|
const mdx = await getMdxContent(locale, 'home');
|
||||||
const allReferences = await getAllReferences(locale);
|
const allReferences = await getAllReferences(locale);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Container, Heading, Badge } from '@/components/ui';
|
import { Container, Heading, Badge } from '@/components/ui';
|
||||||
|
import { getImageProps } from 'next/image';
|
||||||
|
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getAllReferences } from '@/lib/references';
|
import { getAllReferences } from '@/lib/references';
|
||||||
@@ -39,6 +41,30 @@ const mdxComponents = {
|
|||||||
ul: (props: any) => <ul className="grid grid-cols-1 gap-2 mb-6" {...props} />,
|
ul: (props: any) => <ul className="grid grid-cols-1 gap-2 mb-6" {...props} />,
|
||||||
li: CustomLi,
|
li: CustomLi,
|
||||||
p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />,
|
p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />,
|
||||||
|
Heading,
|
||||||
|
ScaleOfImpact,
|
||||||
|
ResponsiveImage: (props: any) => {
|
||||||
|
let src = props.src;
|
||||||
|
if (typeof src === 'string' && src.startsWith('/_next/image')) {
|
||||||
|
try {
|
||||||
|
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
|
||||||
|
if (urlParam) src = decodeURIComponent(urlParam);
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore invalid URL parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { props: { srcSet, src: finalSrc, sizes } } = getImageProps({
|
||||||
|
src,
|
||||||
|
alt: props.alt || '',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
|
||||||
|
quality: 80,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<img {...props} src={finalSrc} srcSet={srcSet} sizes={sizes} loading="lazy" decoding="async" />
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Container } from '@/components/ui';
|
import { Container } from '@/components/ui/Container';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
@@ -33,11 +33,15 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { preload } from 'react-dom';
|
||||||
|
|
||||||
export default async function StandorteOverview(props: { params: Promise<{ locale: string }> }) {
|
export default async function StandorteOverview(props: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await props.params;
|
const { locale } = await props.params;
|
||||||
const safeLocale = locale === 'de' ? 'de' : 'en';
|
const safeLocale = locale === 'de' ? 'de' : 'en';
|
||||||
setRequestLocale(safeLocale);
|
setRequestLocale(safeLocale);
|
||||||
const t = await getTranslations('StandardPage');
|
const t = await getTranslations('StandardPage');
|
||||||
|
|
||||||
|
preload('/germany-map.svg', { as: 'image', fetchPriority: 'high' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
|
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
|
||||||
@@ -70,6 +74,7 @@ export default async function StandorteOverview(props: { params: Promise<{ local
|
|||||||
alt={standort.name}
|
alt={standort.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] filter grayscale-[20%] group-hover:scale-105 group-hover:grayscale-0"
|
className="object-cover transition-transform duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] filter grayscale-[20%] group-hover:scale-105 group-hover:grayscale-0"
|
||||||
|
sizes="(max-width: 768px) 200px, 384px"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/10 transition-colors duration-500" />
|
<div className="absolute inset-0 bg-black/10 transition-colors duration-500" />
|
||||||
|
|
||||||
@@ -85,9 +90,9 @@ export default async function StandorteOverview(props: { params: Promise<{ local
|
|||||||
{/* Content Section */}
|
{/* Content Section */}
|
||||||
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
|
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
|
||||||
<div className="flex items-start justify-between gap-4 mb-4">
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark transition-colors leading-[1.2] group-hover:text-primary">
|
<h2 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark transition-colors leading-[1.2] group-hover:text-primary">
|
||||||
{standort.name}
|
{standort.name}
|
||||||
</h3>
|
</h2>
|
||||||
<ArrowUpRight className="w-5 h-5 text-neutral-400 group-hover:text-primary transition-colors shrink-0" />
|
<ArrowUpRight className="w-5 h-5 text-neutral-400 group-hover:text-primary transition-colors shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,7 +103,7 @@ export default async function StandorteOverview(props: { params: Promise<{ local
|
|||||||
{/* Meta Data */}
|
{/* Meta Data */}
|
||||||
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
|
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">
|
<span className="block text-[10px] uppercase tracking-widest text-neutral-500 font-bold mb-1">
|
||||||
{safeLocale === 'de' ? 'Adresse' : 'Address'}
|
{safeLocale === 'de' ? 'Adresse' : 'Address'}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight">
|
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight">
|
||||||
@@ -117,7 +122,7 @@ export default async function StandorteOverview(props: { params: Promise<{ local
|
|||||||
<div className="mt-12 md:mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group">
|
<div className="mt-12 md:mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group">
|
||||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||||
<div className="relative z-10 max-w-2xl">
|
<div className="relative z-10 max-w-2xl">
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
|
<h2 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h2>
|
||||||
<p className="text-lg text-white/70 mb-8">{t('nextProjectDesc')}</p>
|
<p className="text-lg text-white/70 mb-8">{t('nextProjectDesc')}</p>
|
||||||
<TrackedLink
|
<TrackedLink
|
||||||
href={`/${safeLocale}/${safeLocale === 'de' ? 'kontakt' : 'contact'}`}
|
href={`/${safeLocale}/${safeLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
|
|
||||||
// Notify via Gotify (Internal)
|
// Notify via Gotify (Internal)
|
||||||
await services.notifications.notify({
|
await services.notifications.notify({
|
||||||
title: `📩 ${notificationSubject}`,
|
title: `📩 [E-TIB] ${notificationSubject}`,
|
||||||
message: `New message from ${name} (${email}):\n\n${message}`,
|
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||||
priority: 5,
|
priority: 5,
|
||||||
});
|
});
|
||||||
@@ -153,7 +153,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||||
|
|
||||||
await services.notifications.notify({
|
await services.notifications.notify({
|
||||||
title: '🚨 Contact Form Error',
|
title: '🚨 [E-TIB] Contact Form Error',
|
||||||
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
||||||
priority: 8,
|
priority: 8,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as Sentry from '@sentry/nextjs';
|
|
||||||
import Error from 'next/error';
|
import Error from 'next/error';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ export default function GlobalError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Sentry.captureException(error);
|
console.error('Global Error:', error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,34 @@
|
|||||||
|
|
||||||
@config "../tailwind.config.cjs";
|
@config "../tailwind.config.cjs";
|
||||||
|
|
||||||
|
/* Tailwind v4 Container Polyfill */
|
||||||
|
@utility container {
|
||||||
|
width: 100%;
|
||||||
|
margin-inline: auto !important;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
max-width: 640px;
|
||||||
|
padding-inline: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
max-width: 768px;
|
||||||
|
padding-inline: 2rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
max-width: 1024px;
|
||||||
|
padding-inline: 2.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
max-width: 1280px;
|
||||||
|
padding-inline: 3rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 1536px) {
|
||||||
|
max-width: 1536px;
|
||||||
|
padding-inline: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* E-TIB Custom Utilities */
|
/* E-TIB Custom Utilities */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.text-balance {
|
.text-balance {
|
||||||
@@ -87,3 +115,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* trigger rebuild */
|
/* trigger rebuild */
|
||||||
|
|
||||||
|
@keyframes hero-fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-hero-fade-in-up {
|
||||||
|
animation: hero-fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-zoom-out {
|
||||||
|
from {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-hero-zoom-out {
|
||||||
|
animation: hero-zoom-out 1.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|||||||
BIN
assets/certificates/260325_Rheinland Haftpflichtversicherung.pdf
Normal file
BIN
assets/certificates/260325_Rheinland Haftpflichtversicherung.pdf
Normal file
Binary file not shown.
48
bundle-stats.json
Normal file
48
bundle-stats.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
.next/static/chunks/0b521531212fbb38.js
|
||||||
|
.next/static/chunks/0c4bd4931d57e66a.js
|
||||||
|
.next/static/chunks/114976d6e8233fa6.js
|
||||||
|
.next/static/chunks/163f657a16afbe5e.js
|
||||||
|
.next/static/chunks/1c2f1bc547a6b6db.js
|
||||||
|
.next/static/chunks/2583fa7802927c7b.js
|
||||||
|
.next/static/chunks/30da1bf4e9770388.js
|
||||||
|
.next/static/chunks/38b815a6d50481c3.js
|
||||||
|
.next/static/chunks/3dc2f63532eb2144.js
|
||||||
|
.next/static/chunks/3e327b4e2347bc0f.js
|
||||||
|
.next/static/chunks/3ef3cefd2dae1060.js
|
||||||
|
.next/static/chunks/45499dd88f8aa0be.js
|
||||||
|
.next/static/chunks/4b9828b23c1decfd.js
|
||||||
|
.next/static/chunks/4c801febc70daa53.js
|
||||||
|
.next/static/chunks/4d5ea13dba62fe42.js
|
||||||
|
.next/static/chunks/4dffe404993f353e.js
|
||||||
|
.next/static/chunks/4f185d0c413fdb9e.js
|
||||||
|
.next/static/chunks/4f69e8e09eeae4d6.js
|
||||||
|
.next/static/chunks/4fa8be7f170d31f3.js
|
||||||
|
.next/static/chunks/583b451485662887.js
|
||||||
|
.next/static/chunks/5e1e2d5c357c1b57.js
|
||||||
|
.next/static/chunks/6216b234a7a88a60.js
|
||||||
|
.next/static/chunks/651fbbae0062342b.js
|
||||||
|
.next/static/chunks/66a1b90666fc2830.js
|
||||||
|
.next/static/chunks/6c0ddf2fd63ab912.js
|
||||||
|
.next/static/chunks/72cf73de45fde6cf.js
|
||||||
|
.next/static/chunks/75ea3a278c540e05.js
|
||||||
|
.next/static/chunks/77a84d4dc662b7cf.js
|
||||||
|
.next/static/chunks/7c603cfd5a61abd1.js
|
||||||
|
.next/static/chunks/84ddf763fbc98e13.js
|
||||||
|
.next/static/chunks/885c97d656f95ebd.js
|
||||||
|
.next/static/chunks/8d6ae249fcc5e38f.js
|
||||||
|
.next/static/chunks/920f860e909b2eb8.js
|
||||||
|
.next/static/chunks/951d03271d663d8a.js
|
||||||
|
.next/static/chunks/a094276392fca24a.js
|
||||||
|
.next/static/chunks/a2e7fbe3f294caac.js
|
||||||
|
.next/static/chunks/a6c9d757034eb86a.js
|
||||||
|
.next/static/chunks/a6dc76eec9924929.js
|
||||||
|
.next/static/chunks/c3f21c4df538784a.js
|
||||||
|
.next/static/chunks/c641ee8514fa0552.js
|
||||||
|
.next/static/chunks/d8c0ef20e0ad9790.js
|
||||||
|
.next/static/chunks/e60e6d615bf7d2f5.js
|
||||||
|
.next/static/chunks/e714b6ccaf8d5f12.js
|
||||||
|
.next/static/chunks/eeebcf8c8de1a29a.js
|
||||||
|
.next/static/chunks/f0b47c1f19d25fd2.js
|
||||||
|
.next/static/chunks/f8d5b1a2accefb4c.js
|
||||||
|
.next/static/chunks/fa18a72fd9fb11d5.js
|
||||||
|
.next/static/chunks/turbopack-2aac2a40101e9988.js
|
||||||
@@ -81,7 +81,7 @@ export default function ContactForm() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<Heading level={3} className="mb-4">
|
<Heading level={3} align="center" className="mb-4">
|
||||||
{t('form.successTitle') || 'Message Sent!'}
|
{t('form.successTitle') || 'Message Sent!'}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-text-secondary text-lg mb-8">
|
<p className="text-text-secondary text-lg mb-8">
|
||||||
@@ -115,7 +115,7 @@ export default function ContactForm() {
|
|||||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<Heading level={3} className="mb-4 text-destructive font-black">
|
<Heading level={3} align="center" className="mb-4 text-destructive font-black">
|
||||||
{t('form.errorTitle') || 'Submission Failed!'}
|
{t('form.errorTitle') || 'Submission Failed!'}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const FeedbackOverlay = dynamic(
|
const IS_ENABLED = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||||
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
interface FeedbackClientWrapperProps {
|
const FeedbackOverlay = IS_ENABLED
|
||||||
feedbackEnabled: boolean;
|
? dynamic(
|
||||||
}
|
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
||||||
|
{ ssr: false },
|
||||||
|
)
|
||||||
|
: () => null;
|
||||||
|
|
||||||
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) {
|
export default function FeedbackClientWrapper() {
|
||||||
if (!feedbackEnabled) return null;
|
if (!IS_ENABLED) return null;
|
||||||
|
|
||||||
return <FeedbackOverlay />;
|
return <FeedbackOverlay />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
import { m, AnimatePresence, LazyMotion } from 'framer-motion';
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
interface LightboxProps {
|
interface LightboxProps {
|
||||||
|
|||||||
@@ -2,34 +2,28 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { getAppServices } from '@/lib/services/create-services';
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AnalyticsProvider Component
|
* AnalyticsProvider Component
|
||||||
*
|
*
|
||||||
* Automatically tracks pageviews on client-side route changes.
|
* Automatically tracks pageviews on client-side route changes.
|
||||||
* This component handles navigation events for the Umami analytics service.
|
* This component handles navigation events for the Umami analytics service.
|
||||||
*
|
|
||||||
* Note: Website ID is now centrally managed on the server side via a proxy,
|
|
||||||
* so it's no longer needed as a prop here.
|
|
||||||
*/
|
*/
|
||||||
export default function AnalyticsProvider() {
|
export default function AnalyticsProvider() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { trackPageview } = useAnalytics();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pathname) return;
|
if (!pathname) return;
|
||||||
|
|
||||||
const services = getAppServices();
|
|
||||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|
||||||
// Track pageview with the full URL
|
// Track pageview with the full URL
|
||||||
// The service will relay this to our internal proxy which injects the Website ID
|
// The service will relay this to our internal proxy which injects the Website ID
|
||||||
services.analytics.trackPageview(url);
|
trackPageview(url);
|
||||||
|
}, [pathname, searchParams, trackPageview]);
|
||||||
// Services like logger are already sub-initialized in getAppServices()
|
|
||||||
// so we don't need to log here manually.
|
|
||||||
}, [pathname, searchParams]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,18 @@ export default function AnalyticsShell() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initServices = () => {
|
||||||
|
import('@/lib/services/create-services').then(({ getAppServices }) => {
|
||||||
|
getAppServices();
|
||||||
|
setShouldLoad(true);
|
||||||
|
}).catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
|
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
|
||||||
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||||
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
|
window.requestIdleCallback(initServices, { timeout: 3000 });
|
||||||
} else {
|
} else {
|
||||||
const timer = setTimeout(() => setShouldLoad(true), 2500);
|
const timer = setTimeout(initServices, 2500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -13,13 +13,7 @@ export default function ClientNotFoundTracker({ path }: { path: string }) {
|
|||||||
path,
|
path,
|
||||||
});
|
});
|
||||||
|
|
||||||
import('@sentry/nextjs').then((Sentry) => {
|
|
||||||
Sentry.withScope((scope) => {
|
|
||||||
scope.setTag('status_code', '404');
|
|
||||||
scope.setTag('path', path);
|
|
||||||
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [trackEvent, path]);
|
}, [trackEvent, path]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,73 +1,69 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { getAppServices } from '@/lib/services/create-services';
|
|
||||||
import type { AnalyticsEventProperties } from '@/lib/services/analytics/analytics-service';
|
export type AnalyticsEventProperties = Record<string, any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a payload to the proxy API without loading backend config/logger dependencies.
|
||||||
|
*/
|
||||||
|
function sendAnalyticsPayload(type: 'event', data: Record<string, any>) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (process.env.NEXT_PUBLIC_CI === 'true') return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
hostname: window.location.hostname,
|
||||||
|
screen: `${window.screen.width}x${window.screen.height}`,
|
||||||
|
language: navigator.language,
|
||||||
|
referrer: document.referrer,
|
||||||
|
title: document.title,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetch('/stats/api/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, payload }),
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(e => console.error('[Umami] Failed to send payload', e));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Umami] Error sending analytics', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for tracking analytics events with Umami.
|
* Custom hook for tracking analytics events with Umami.
|
||||||
*
|
|
||||||
* Provides a convenient way to track custom events throughout your application.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* import { useAnalytics } from '@/components/analytics/useAnalytics';
|
|
||||||
*
|
|
||||||
* function MyComponent() {
|
|
||||||
* const { trackEvent, trackPageview } = useAnalytics();
|
|
||||||
*
|
|
||||||
* const handleButtonClick = () => {
|
|
||||||
* trackEvent('button_click', {
|
|
||||||
* button_id: 'cta-primary',
|
|
||||||
* page: 'homepage'
|
|
||||||
* });
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* return <button onClick={handleButtonClick}>Click me</button>;
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // Track a custom pageview
|
|
||||||
* const { trackPageview } = useAnalytics();
|
|
||||||
* trackPageview('/custom-path?param=value');
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export function useAnalytics() {
|
export function useAnalytics() {
|
||||||
const services = getAppServices();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track a custom event with optional properties.
|
|
||||||
*
|
|
||||||
* @param eventName - The name of the event to track
|
|
||||||
* @param properties - Optional event properties (metadata)
|
|
||||||
*/
|
|
||||||
const trackEvent = useCallback(
|
const trackEvent = useCallback(
|
||||||
(eventName: string, properties?: AnalyticsEventProperties) => {
|
(eventName: string, properties?: AnalyticsEventProperties) => {
|
||||||
services.analytics.track(eventName, properties);
|
sendAnalyticsPayload('event', {
|
||||||
|
name: eventName,
|
||||||
|
data: properties,
|
||||||
|
url: window.location.pathname + window.location.search,
|
||||||
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log('[Umami] Tracked event:', eventName, properties);
|
console.log('[Umami] Tracked event:', eventName, properties);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[services]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Track a pageview (useful for custom navigation or SPA routing).
|
|
||||||
*
|
|
||||||
* @param url - The URL to track (defaults to current location)
|
|
||||||
*/
|
|
||||||
const trackPageview = useCallback(
|
const trackPageview = useCallback(
|
||||||
(url?: string) => {
|
(url?: string) => {
|
||||||
services.analytics.trackPageview(url);
|
sendAnalyticsPayload('event', {
|
||||||
|
url: url || window.location.pathname + window.location.search,
|
||||||
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log('[Umami] Tracked pageview:', url ?? 'current location');
|
console.log('[Umami] Tracked pageview:', url ?? 'current location');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[services]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { AnimatePresence, m, LazyMotion, domAnimation } from 'framer-motion';
|
import { AnimatePresence, m } from 'framer-motion';
|
||||||
import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react";
|
import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react";
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
|
import { m, Variants } from 'framer-motion';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
import { LogoArcs } from '@/components/ui/LogoArcs';
|
import { LogoArcs } from '@/components/ui/LogoArcs';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { Container } from '@/components/ui/Container';
|
import { Container } from '@/components/ui/Container';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
@@ -25,7 +25,7 @@ interface CertificatesBlockProps {
|
|||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCertificates: Certificate[] = [
|
export const defaultCertificates: Certificate[] = [
|
||||||
{
|
{
|
||||||
title: 'ISO 14001:2015',
|
title: 'ISO 14001:2015',
|
||||||
description: 'Umweltmanagementsystem',
|
description: 'Umweltmanagementsystem',
|
||||||
@@ -47,6 +47,13 @@ const defaultCertificates: Certificate[] = [
|
|||||||
type: 'iso',
|
type: 'iso',
|
||||||
date: '05.12.2025',
|
date: '05.12.2025',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Betriebshaftpflichtversicherung',
|
||||||
|
description: 'RheinLand Versicherungen (10 Mio. € Deckung)',
|
||||||
|
pdfUrl: '/assets/certificates/betriebshaftpflicht.pdf',
|
||||||
|
type: 'general',
|
||||||
|
date: '25.03.2026',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Freistellungsbescheinigung',
|
title: 'Freistellungsbescheinigung',
|
||||||
description: 'Nach § 48 b EStG',
|
description: 'Nach § 48 b EStG',
|
||||||
@@ -68,7 +75,6 @@ const defaultCertificates: Certificate[] = [
|
|||||||
type: 'tax',
|
type: 'tax',
|
||||||
date: '13.02.2025',
|
date: '13.02.2025',
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) {
|
export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) {
|
||||||
@@ -148,7 +154,8 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
const wrapperProps = cert.pdfUrl ? {
|
const wrapperProps = cert.pdfUrl ? {
|
||||||
href: encodeURI(cert.pdfUrl),
|
href: encodeURI(cert.pdfUrl),
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
rel: "noopener noreferrer"
|
rel: "noopener noreferrer",
|
||||||
|
download: cert.pdfUrl.split('/').pop()
|
||||||
} : {};
|
} : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -201,7 +208,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`mt-auto inline-flex items-center text-sm font-bold uppercase tracking-wider transition-all ${
|
<div className={`mt-auto inline-flex items-center gap-2 text-sm font-bold uppercase tracking-wider transition-all duration-300 ${
|
||||||
cert.pdfUrl ? 'group-hover:gap-3' : ''
|
cert.pdfUrl ? 'group-hover:gap-3' : ''
|
||||||
} ${
|
} ${
|
||||||
isIso ? 'text-primary-light' : 'text-primary'
|
isIso ? 'text-primary-light' : 'text-primary'
|
||||||
@@ -209,7 +216,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
{cert.pdfUrl ? (
|
{cert.pdfUrl ? (
|
||||||
<>
|
<>
|
||||||
<span>Download PDF</span>
|
<span>Download PDF</span>
|
||||||
<Download size={16} className="ml-2 group-hover:translate-y-0.5 transition-transform" />
|
<Download size={16} className="group-hover:translate-y-0.5 transition-transform" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-neutral-400">Nachweis liegt vor</span>
|
<span className="text-neutral-400">Nachweis liegt vor</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useScroll, useTransform, m, LazyMotion, domAnimation } from 'framer-motion';
|
import { useScroll, useTransform, m } from 'framer-motion';
|
||||||
import { Building2, Compass, Layers, ArrowDownToLine, Wrench, Factory, Zap, MapPin, CheckCircle2 } from 'lucide-react';
|
import { Building2, Compass, Layers, ArrowDownToLine, Wrench, Factory, Zap, MapPin, CheckCircle2 } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export function CompanyTimeline({
|
|||||||
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" />
|
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" />
|
||||||
<div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" />
|
<div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" />
|
||||||
|
|
||||||
<div className="container max-w-5xl relative z-10">
|
<div className="container mx-auto max-w-5xl relative z-10">
|
||||||
<div className="text-center mb-12 md:mb-24">
|
<div className="text-center mb-12 md:mb-24">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-neutral-200 text-neutral-dark text-xs font-bold uppercase tracking-wider mb-6 shadow-sm">
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-neutral-200 text-neutral-dark text-xs font-bold uppercase tracking-wider mb-6 shadow-sm">
|
||||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import React, { Suspense } from 'react';
|
|||||||
import { Section, Container } from '@/components/ui';
|
import { Section, Container } from '@/components/ui';
|
||||||
import { ContactForm } from '@/components/forms/ContactForm';
|
import { ContactForm } from '@/components/forms/ContactForm';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
const ContactMap = dynamic(() => import('@/components/ContactMap'), {
|
const ContactMap = dynamic(() => import('@/components/ContactMap'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
export function DataGridPulse() {
|
export function DataGridPulse() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useScroll, useTransform, m, LazyMotion, domAnimation } from 'framer-motion';
|
import { useScroll, useTransform, m } from 'framer-motion';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
export function DeepDrillAnimation() {
|
export function DeepDrillAnimation() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export function GrowthChart() {
|
export function GrowthChart() {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
|
||||||
import { Badge, Container, Heading } from '@/components/ui';
|
import { Badge, Container, Heading } from '@/components/ui';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
@@ -17,25 +16,7 @@ export interface HeroSectionProps {
|
|||||||
ctaHref?: string;
|
ctaHref?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerVariants = {
|
// CSS animations replace Framer Motion for better LCP
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.15,
|
|
||||||
delayChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { opacity: 0, y: 30 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||||
const { title, badge, subtitle, backgroundImage, alignment, ctaLabel, ctaHref } = props;
|
const { title, badge, subtitle, backgroundImage, alignment, ctaLabel, ctaHref } = props;
|
||||||
@@ -47,12 +28,9 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
|||||||
className={`relative min-h-[35vh] md:min-h-[50vh] flex items-center pt-28 pb-10 md:pt-36 md:pb-24 overflow-hidden bg-neutral-dark ${alignment === 'center' ? 'justify-center text-center' : ''}`}
|
className={`relative min-h-[35vh] md:min-h-[50vh] flex items-center pt-28 pb-10 md:pt-36 md:pb-24 overflow-hidden bg-neutral-dark ${alignment === 'center' ? 'justify-center text-center' : ''}`}
|
||||||
>
|
>
|
||||||
{bgSrc && (
|
{bgSrc && (
|
||||||
<m.div
|
<div
|
||||||
key={`hero-bg-${pathname}`}
|
key={`hero-bg-${pathname}`}
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0 animate-hero-zoom-out"
|
||||||
initial={{ scale: 1.05, opacity: 1 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={bgSrc}
|
src={bgSrc}
|
||||||
@@ -74,37 +52,34 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
|||||||
|
|
||||||
{/* Top Fade for Header Navigation Readability */}
|
{/* Top Fade for Header Navigation Readability */}
|
||||||
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
||||||
</m.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
|
<Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
|
||||||
<m.div
|
<div
|
||||||
key={`hero-content-${pathname}`}
|
key={`hero-content-${pathname}`}
|
||||||
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
|
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
>
|
>
|
||||||
{badge && (
|
{badge && (
|
||||||
<m.div variants={itemVariants}>
|
<div className="animate-hero-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
||||||
{badge}
|
{badge}
|
||||||
</Badge>
|
</Badge>
|
||||||
</m.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<m.div variants={itemVariants}>
|
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', animationFillMode: 'both' }}>
|
||||||
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
|
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
</m.div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<m.div variants={itemVariants}>
|
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', animationFillMode: 'both' }}>
|
||||||
<p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}>
|
<p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
</m.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ctaLabel && ctaHref && (
|
{ctaLabel && ctaHref && (
|
||||||
<m.div variants={itemVariants} className={`mt-8 ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`}>
|
<div className={`mt-8 animate-hero-fade-in-up ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`} style={{ animationDelay: badge ? '0.55s' : '0.4s', animationFillMode: 'both' }}>
|
||||||
<Button
|
<Button
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
@@ -115,9 +90,9 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
|||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</m.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</m.div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,12 +86,14 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
src={posterSrc}
|
src={posterSrc}
|
||||||
alt={posterAlt}
|
alt={posterAlt}
|
||||||
fill
|
fill
|
||||||
className="object-cover z-[1] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
className="object-cover z-[1] pointer-events-none"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
quality={25}
|
quality={25}
|
||||||
priority
|
priority
|
||||||
fetchPriority="high"
|
fetchPriority="high"
|
||||||
/>
|
/>
|
||||||
|
{/* Fast compositing layer to replace the heavy CSS filters on the image */}
|
||||||
|
<div className="absolute inset-0 bg-black/20 z-[1] pointer-events-none mix-blend-multiply" />
|
||||||
|
|
||||||
{/* Render video on top if available, but defer src to avoid blocking LCP */}
|
{/* Render video on top if available, but defer src to avoid blocking LCP */}
|
||||||
{videoUrl && (
|
{videoUrl && (
|
||||||
@@ -117,7 +119,7 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
{/* Top Fade for Header Navigation Readability */}
|
{/* Top Fade for Header Navigation Readability */}
|
||||||
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
||||||
|
|
||||||
<div className="container relative z-[4] text-center px-4">
|
<div className="container mx-auto max-w-7xl relative z-[4] text-center px-4">
|
||||||
<div
|
<div
|
||||||
key={`hero-content-${pathname}`}
|
key={`hero-content-${pathname}`}
|
||||||
className="relative"
|
className="relative"
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
import { MapPin, ArrowUpRight } from 'lucide-react';
|
||||||
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
||||||
import { Location, projectLocations } from '@/lib/map-data';
|
import { Location, projectLocations } from '@/lib/map-data';
|
||||||
import { standorteLocations } from '@/lib/standorte-data';
|
import { standorteLocations } from '@/lib/standorte-data';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
const InteractiveMapPins = dynamic(() => import('./InteractiveMapPins'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
interface Stat {
|
interface Stat {
|
||||||
value: string;
|
value: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
@@ -28,58 +32,7 @@ interface InteractiveGermanyMapProps {
|
|||||||
hideStandorte?: boolean;
|
hideStandorte?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
|
|
||||||
<div
|
|
||||||
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
|
|
||||||
style={{
|
|
||||||
left: `${loc.x}%`,
|
|
||||||
top: `${loc.y}%`,
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => onEnter(loc)}
|
|
||||||
onMouseLeave={onLeave}
|
|
||||||
onTouchStart={() => onEnter(loc)}
|
|
||||||
onClick={() => onEnter(loc)}
|
|
||||||
>
|
|
||||||
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
|
|
||||||
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
MinorNode.displayName = 'MinorNode';
|
|
||||||
|
|
||||||
const MajorNode = React.memo(({ loc, isActive, idx, onEnter, onLeave }: { loc: Location, isActive: boolean, idx: number, onEnter: (loc: Location) => void, onLeave: () => void }) => {
|
|
||||||
const isHQ = loc.type === 'hq';
|
|
||||||
const isBranch = loc.type === 'branch';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
|
|
||||||
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
|
||||||
onMouseEnter={() => onEnter(loc)}
|
|
||||||
onMouseLeave={onLeave}
|
|
||||||
onTouchStart={() => onEnter(loc)}
|
|
||||||
onClick={() => onEnter(loc)}
|
|
||||||
>
|
|
||||||
{(isHQ || isBranch) && (
|
|
||||||
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
|
||||||
)}
|
|
||||||
<m.div
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: 'spring', delay: idx * 0.1 }}
|
|
||||||
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
|
||||||
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
|
||||||
} ${
|
|
||||||
isHQ || isBranch
|
|
||||||
? 'w-7 h-7 bg-primary text-[#050B14]'
|
|
||||||
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
|
||||||
</m.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
MajorNode.displayName = 'MajorNode';
|
|
||||||
|
|
||||||
export function InteractiveGermanyMap({
|
export function InteractiveGermanyMap({
|
||||||
badge,
|
badge,
|
||||||
@@ -135,12 +88,12 @@ export function InteractiveGermanyMap({
|
|||||||
|
|
||||||
const mapBackground = useMemo(() => (
|
const mapBackground = useMemo(() => (
|
||||||
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu">
|
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu">
|
||||||
<Image
|
<img
|
||||||
src="/germany-map.svg"
|
src="/germany-map.svg"
|
||||||
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
||||||
fill
|
className="w-full h-full object-cover"
|
||||||
className="object-cover"
|
fetchPriority="high"
|
||||||
priority
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
), [locale]);
|
), [locale]);
|
||||||
@@ -210,116 +163,14 @@ export function InteractiveGermanyMap({
|
|||||||
|
|
||||||
{mapBackground}
|
{mapBackground}
|
||||||
|
|
||||||
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
<InteractiveMapPins
|
||||||
<MinorNode
|
locations={finalLocations}
|
||||||
key={`minor-${loc.id || idx}`}
|
locale={locale}
|
||||||
loc={loc}
|
t={t}
|
||||||
isActive={activeLocation === loc}
|
activeLocation={activeLocation}
|
||||||
onEnter={handleMouseEnter}
|
handleMouseEnter={handleMouseEnter}
|
||||||
onLeave={handleMouseLeave}
|
handleMouseLeave={handleMouseLeave}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
|
|
||||||
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
|
|
||||||
<MajorNode
|
|
||||||
key={loc.id}
|
|
||||||
loc={loc}
|
|
||||||
idx={idx}
|
|
||||||
isActive={activeLocation?.id === loc.id}
|
|
||||||
onEnter={handleMouseEnter}
|
|
||||||
onLeave={handleMouseLeave}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{activeLocation && (
|
|
||||||
<m.div
|
|
||||||
key="tooltip"
|
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="absolute z-[9999] pointer-events-auto bottom-[-60px] left-1/2 -translate-x-1/2 w-[calc(100vw_-_2rem)] max-w-[320px] md:w-max md:bottom-auto md:left-[var(--md-left)] md:top-[var(--md-top)] md:[transform:var(--md-transform)]"
|
|
||||||
style={{
|
|
||||||
'--md-left': `${activeLocation.x}%`,
|
|
||||||
'--md-top': `${activeLocation.y}%`,
|
|
||||||
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
|
|
||||||
{activeLocation.featuredImage && (
|
|
||||||
<div className="relative w-full h-32 bg-neutral-900">
|
|
||||||
<Image
|
|
||||||
src={activeLocation.featuredImage}
|
|
||||||
alt={activeLocation.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] to-transparent opacity-90" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<MapPin className="w-3.5 h-3.5 text-primary" />
|
|
||||||
<span className="text-xs font-bold text-primary uppercase tracking-wider">
|
|
||||||
{t(`types.${activeLocation.type === 'minor_node' ? 'project' : activeLocation.type}`)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="font-heading font-bold text-lg leading-tight mb-2">
|
|
||||||
{activeLocation.name}
|
|
||||||
</div>
|
|
||||||
{activeLocation.type !== 'minor_node' ? (
|
|
||||||
<div className="text-white/60 text-sm line-clamp-3 mb-3">
|
|
||||||
{activeLocation.description}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-white/60 text-xs mb-3">
|
|
||||||
{t(`descriptions.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(activeLocation.details || activeLocation.type === 'minor_node') && (
|
|
||||||
<ul className="mb-3 space-y-1.5 border-t border-white/10 pt-3">
|
|
||||||
{activeLocation.type === 'minor_node' ? (
|
|
||||||
<>
|
|
||||||
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
||||||
<span>{t(`features.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}</span>
|
|
||||||
</li>
|
|
||||||
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
||||||
<span>{t('features.method')}</span>
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
activeLocation.details?.map((detail, i) => (
|
|
||||||
<li key={i} className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
||||||
<span>{detail}</span>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeLocation.href && (activeLocation.type === 'hq' || activeLocation.type === 'branch') && (
|
|
||||||
<Link
|
|
||||||
href={activeLocation.href.startsWith('/') ? `/${locale}${activeLocation.href}` : activeLocation.href}
|
|
||||||
className="inline-flex items-center text-xs font-bold text-primary hover:text-white transition-colors mt-2"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{t('learnMore')}
|
|
||||||
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</m.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
195
components/blocks/InteractiveMapPins.tsx
Normal file
195
components/blocks/InteractiveMapPins.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { m, AnimatePresence, LazyMotion } from 'framer-motion';
|
||||||
|
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Location } from '@/lib/map-data';
|
||||||
|
|
||||||
|
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
|
||||||
|
|
||||||
|
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
|
||||||
|
<div
|
||||||
|
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
|
||||||
|
style={{
|
||||||
|
left: `${loc.x}%`,
|
||||||
|
top: `${loc.y}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onEnter(loc)}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
onTouchStart={() => onEnter(loc)}
|
||||||
|
onClick={() => onEnter(loc)}
|
||||||
|
>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
|
||||||
|
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
MinorNode.displayName = 'MinorNode';
|
||||||
|
|
||||||
|
const MajorNode = React.memo(({ loc, isActive, idx, onEnter, onLeave }: { loc: Location, isActive: boolean, idx: number, onEnter: (loc: Location) => void, onLeave: () => void }) => {
|
||||||
|
const isHQ = loc.type === 'hq';
|
||||||
|
const isBranch = loc.type === 'branch';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
|
||||||
|
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
||||||
|
onMouseEnter={() => onEnter(loc)}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
onTouchStart={() => onEnter(loc)}
|
||||||
|
onClick={() => onEnter(loc)}
|
||||||
|
>
|
||||||
|
{(isHQ || isBranch) && (
|
||||||
|
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
||||||
|
)}
|
||||||
|
<m.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', delay: idx * 0.1 }}
|
||||||
|
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
||||||
|
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
||||||
|
} ${
|
||||||
|
isHQ || isBranch
|
||||||
|
? 'w-7 h-7 bg-primary text-[#050B14]'
|
||||||
|
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
||||||
|
</m.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
MajorNode.displayName = 'MajorNode';
|
||||||
|
|
||||||
|
export default function InteractiveMapPins({
|
||||||
|
locations,
|
||||||
|
locale,
|
||||||
|
t,
|
||||||
|
activeLocation,
|
||||||
|
handleMouseEnter,
|
||||||
|
handleMouseLeave
|
||||||
|
}: {
|
||||||
|
locations: Location[],
|
||||||
|
locale: string,
|
||||||
|
t: any,
|
||||||
|
activeLocation: Location | null,
|
||||||
|
handleMouseEnter: (loc: Location) => void,
|
||||||
|
handleMouseLeave: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<LazyMotion features={loadFeatures}>
|
||||||
|
{locations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
||||||
|
<MinorNode
|
||||||
|
key={`minor-${loc.id || idx}`}
|
||||||
|
loc={loc}
|
||||||
|
isActive={activeLocation === loc}
|
||||||
|
onEnter={handleMouseEnter}
|
||||||
|
onLeave={handleMouseLeave}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{locations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
|
||||||
|
<MajorNode
|
||||||
|
key={loc.id}
|
||||||
|
loc={loc}
|
||||||
|
idx={idx}
|
||||||
|
isActive={activeLocation?.id === loc.id}
|
||||||
|
onEnter={handleMouseEnter}
|
||||||
|
onLeave={handleMouseLeave}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{activeLocation && (
|
||||||
|
<m.div
|
||||||
|
key="tooltip"
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute z-[9999] pointer-events-auto bottom-[-60px] left-1/2 -translate-x-1/2 w-[calc(100vw_-_2rem)] max-w-[320px] md:w-max md:bottom-auto md:left-[var(--md-left)] md:top-[var(--md-top)] md:[transform:var(--md-transform)]"
|
||||||
|
style={{
|
||||||
|
'--md-left': `${activeLocation.x}%`,
|
||||||
|
'--md-top': `${activeLocation.y}%`,
|
||||||
|
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||||
|
} as React.CSSProperties}
|
||||||
|
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
|
||||||
|
{activeLocation.featuredImage && (
|
||||||
|
<div className="relative w-full h-32 bg-neutral-900">
|
||||||
|
<Image
|
||||||
|
src={activeLocation.featuredImage}
|
||||||
|
alt={activeLocation.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 320px"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] to-transparent opacity-90" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-primary" />
|
||||||
|
<span className="text-xs font-bold text-primary uppercase tracking-wider">
|
||||||
|
{t(`types.${activeLocation.type === 'minor_node' ? 'project' : activeLocation.type}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-heading font-bold text-lg leading-tight mb-2">
|
||||||
|
{activeLocation.name}
|
||||||
|
</div>
|
||||||
|
{activeLocation.type !== 'minor_node' ? (
|
||||||
|
<div className="text-white/60 text-sm line-clamp-3 mb-3">
|
||||||
|
{activeLocation.description}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-white/60 text-xs mb-3">
|
||||||
|
{t(`descriptions.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(activeLocation.details || activeLocation.type === 'minor_node') && (
|
||||||
|
<ul className="mb-3 space-y-1.5 border-t border-white/10 pt-3">
|
||||||
|
{activeLocation.type === 'minor_node' ? (
|
||||||
|
<>
|
||||||
|
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
||||||
|
<span>{t(`features.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}</span>
|
||||||
|
</li>
|
||||||
|
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
||||||
|
<span>{t('features.method')}</span>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
activeLocation.details?.map((detail, i) => (
|
||||||
|
<li key={i} className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
||||||
|
<span>{detail}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeLocation.href && (activeLocation.type === 'hq' || activeLocation.type === 'branch') && (
|
||||||
|
<Link
|
||||||
|
href={activeLocation.href.startsWith('/') ? `/${locale}${activeLocation.href}` : activeLocation.href}
|
||||||
|
className="inline-flex items-center text-xs font-bold text-primary hover:text-white transition-colors mt-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{t('learnMore')}
|
||||||
|
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</LazyMotion>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import React from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export interface JobListingBlockProps {
|
export interface JobListingBlockProps {
|
||||||
@@ -53,7 +53,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
showFairs,
|
showFairs,
|
||||||
fairsTitle = t('fairsTitle'),
|
fairsTitle = t('fairsTitle'),
|
||||||
fairs = [
|
fairs = [
|
||||||
{ name: 'Intersolar München', date: 'Termin folgt', type: 'Energie-Messe', location: 'Messe München' },
|
{ name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Energie-Messe', location: 'Messe München' },
|
||||||
{ name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' },
|
{ name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' },
|
||||||
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
|
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
|
||||||
],
|
],
|
||||||
@@ -69,7 +69,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-12 md:py-24">
|
<div className="py-12 md:py-24">
|
||||||
<div className="container mx-auto">
|
<div className="container max-w-7xl mx-auto">
|
||||||
{showFairs && (
|
{showFairs && (
|
||||||
<div className="mb-12 md:mb-24 relative">
|
<div className="mb-12 md:mb-24 relative">
|
||||||
<m.h3
|
<m.h3
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
@@ -47,19 +47,6 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
const isDraggingRef = React.useRef(false);
|
const isDraggingRef = React.useRef(false);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
|
||||||
// Center the first card on mount so it appears in the middle with peek on both sides
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
// Wait for layout to settle
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const firstCard = el.querySelector('[data-card]') as HTMLElement | null;
|
|
||||||
if (!firstCard) return;
|
|
||||||
const cardCenter = firstCard.offsetLeft + firstCard.offsetWidth / 2;
|
|
||||||
const containerCenter = el.offsetWidth / 2;
|
|
||||||
el.scrollLeft = cardCenter - containerCenter;
|
|
||||||
});
|
|
||||||
}, [references.length]);
|
|
||||||
|
|
||||||
const badge = props.badge || data?.badge || t('badge');
|
const badge = props.badge || data?.badge || t('badge');
|
||||||
const title = props.title || data?.title || t('title');
|
const title = props.title || data?.title || t('title');
|
||||||
@@ -111,7 +98,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
<section id="referenzen" className="pt-16 pb-8 md:py-24 lg:py-32 bg-neutral-dark text-white relative overflow-hidden">
|
<section id="referenzen" className="pt-16 pb-8 md:py-24 lg:py-32 bg-neutral-dark text-white relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" />
|
<div className="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" />
|
||||||
|
|
||||||
<div className="container relative z-10 mb-12 flex flex-col items-center justify-center text-center gap-6">
|
<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>
|
<div>
|
||||||
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
||||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
|
<h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
|
||||||
@@ -127,7 +114,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 pointer-events-none" />
|
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 pointer-events-none" />
|
||||||
|
|
||||||
{/* Carousel: centered first card with peek on both sides */}
|
{/* Carousel: centered first card with peek on both sides */}
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
@@ -189,10 +176,10 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</LazyMotion>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container relative z-10 mt-4 flex justify-center">
|
<div className="container mx-auto max-w-7xl relative z-10 mt-4 flex justify-center">
|
||||||
<Link
|
<Link
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
className="px-8 py-4 rounded-xl bg-white/5 border border-white/20 hover:bg-white hover:text-neutral-dark hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] transition-all font-bold flex items-center gap-3 backdrop-blur-sm"
|
className="px-8 py-4 rounded-xl bg-white/5 border border-white/20 hover:bg-white hover:text-neutral-dark hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] transition-all font-bold flex items-center gap-3 backdrop-blur-sm"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useScroll, useTransform, m, LazyMotion, domAnimation } from 'framer-motion';
|
import { useScroll, useTransform, m } from 'framer-motion';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
export function ScaleOfImpact() {
|
export function ScaleOfImpact() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
|
import { m, Variants } from 'framer-motion';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
|
|
||||||
export interface ServicePanelData {
|
export interface ServicePanelData {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
|
import { m, Variants } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
@@ -69,7 +69,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="unternehmen" className="pt-12 pb-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
<section id="unternehmen" className="pt-12 pb-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
||||||
<div className="container relative z-10 px-4">
|
<div className="container mx-auto max-w-7xl relative z-10 px-4">
|
||||||
|
|
||||||
{(badge || title) && (
|
{(badge || title) && (
|
||||||
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
||||||
@@ -128,7 +128,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
? 'opacity-30 grayscale-0 scale-105'
|
? 'opacity-30 grayscale-0 scale-105'
|
||||||
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110'
|
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110'
|
||||||
}`}
|
}`}
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
sizes="(max-width: 768px) 200px, 384px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
export interface SupportCTAProps {
|
export interface SupportCTAProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-16 md:py-24 lg:py-32 bg-white relative overflow-hidden">
|
<section className="py-16 md:py-24 lg:py-32 bg-white relative overflow-hidden">
|
||||||
<div className="container relative z-10">
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl relative z-10">
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
|
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
|
||||||
<h3 className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-4">{t('title')}</h3>
|
<h3 className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-4">{t('title')}</h3>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { useScroll, useTransform, useSpring, m, LazyMotion, domAnimation } from 'framer-motion';
|
import { useScroll, useTransform, useSpring, m } from 'framer-motion';
|
||||||
import { LogoArcs } from '@/components/ui/LogoArcs';
|
import { LogoArcs } from '@/components/ui/LogoArcs';
|
||||||
|
|
||||||
function MagneticRing({
|
function MagneticRing({
|
||||||
@@ -25,34 +25,45 @@ function MagneticRing({
|
|||||||
// Only run on client with mouse
|
// Only run on client with mouse
|
||||||
if (window.matchMedia("(pointer: coarse)").matches) return;
|
if (window.matchMedia("(pointer: coarse)").matches) return;
|
||||||
|
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const rect = ref.current.getBoundingClientRect();
|
|
||||||
const centerX = rect.left + rect.width / 2;
|
if (!ticking) {
|
||||||
const centerY = rect.top + rect.height / 2;
|
window.requestAnimationFrame(() => {
|
||||||
|
if (!ref.current) {
|
||||||
|
ticking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = ref.current.getBoundingClientRect();
|
||||||
|
const centerX = rect.left + rect.width / 2;
|
||||||
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
const distX = e.clientX - centerX;
|
const distX = e.clientX - centerX;
|
||||||
const distY = e.clientY - centerY;
|
const distY = e.clientY - centerY;
|
||||||
const distance = Math.sqrt(distX * Math.pow(distX, 2) + Math.pow(distY, 2)); // Fix: correct pythagoras
|
const actualDistance = Math.sqrt(distX * distX + distY * distY);
|
||||||
|
|
||||||
// Calculate actual distance correctly
|
if (actualDistance < pullRadius) {
|
||||||
const actualDistance = Math.sqrt(distX * distX + distY * distY);
|
// Exponential falloff for natural magnetic feel
|
||||||
|
const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2);
|
||||||
if (actualDistance < pullRadius) {
|
|
||||||
// Exponential falloff for natural magnetic feel
|
// Target offset (fraction of the distance based on strength)
|
||||||
const pullFactor = Math.pow(1 - (actualDistance / pullRadius), 2);
|
x.set(distX * pullFactor * (pullStrength / 100));
|
||||||
|
y.set(distY * pullFactor * (pullStrength / 100));
|
||||||
// Target offset (fraction of the distance based on strength)
|
} else {
|
||||||
x.set(distX * pullFactor * (pullStrength / 100));
|
// Return to origin smoothly
|
||||||
y.set(distY * pullFactor * (pullStrength / 100));
|
x.set(0);
|
||||||
} else {
|
y.set(0);
|
||||||
// Return to origin smoothly
|
}
|
||||||
x.set(0);
|
ticking = false;
|
||||||
y.set(0);
|
});
|
||||||
|
ticking = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
window.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||||
}, [x, y, pullRadius, pullStrength]);
|
}, [x, y, pullRadius, pullStrength]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
export function EUFundingBadge() {
|
export function EUFundingBadge() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
|
|
||||||
|
|||||||
7
components/layout/DynamicMobileBottomNav.tsx
Normal file
7
components/layout/DynamicMobileBottomNav.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
export const DynamicMobileBottomNav = dynamic(
|
||||||
|
() => import('./MobileBottomNav').then(mod => mod.MobileBottomNav),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
@@ -29,7 +29,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
|
|
||||||
<EUFundingBadge />
|
<EUFundingBadge />
|
||||||
|
|
||||||
<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">
|
<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">
|
||||||
{/* Brand Column Bento */}
|
{/* Brand Column Bento */}
|
||||||
<div className="col-span-1 md:col-span-4 lg:col-span-5 flex flex-col justify-between bg-white/[0.02] border border-white/5 rounded-3xl p-5 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden">
|
<div className="col-span-1 md:col-span-4 lg:col-span-5 flex flex-col justify-between bg-white/[0.02] border border-white/5 rounded-3xl p-5 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
||||||
@@ -217,7 +217,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container relative z-10">
|
<div className="container mx-auto max-w-7xl relative z-10">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 md:gap-6 py-4 px-5 md:py-6 md:px-8 rounded-2xl bg-white/[0.02] border border-white/5 backdrop-blur-md text-center md:text-left">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-4 md:gap-6 py-4 px-5 md:py-6 md:px-8 rounded-2xl bg-white/[0.02] border border-white/5 backdrop-blur-md text-center md:text-left">
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-6 h-full">
|
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-6 h-full">
|
||||||
<p className="text-neutral-400 text-xs font-medium m-0">© {new Date().getFullYear()} E-TIB GmbH. {locale === 'de' ? 'Alle Rechte vorbehalten.' : 'All rights reserved.'}</p>
|
<p className="text-neutral-400 text-xs font-medium m-0">© {new Date().getFullYear()} E-TIB GmbH. {locale === 'de' ? 'Alle Rechte vorbehalten.' : 'All rights reserved.'}</p>
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
priority
|
priority
|
||||||
fetchPriority="high"
|
fetchPriority="high"
|
||||||
sizes="192px"
|
sizes="192px"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
{/* Masked sweep overlay on hover */}
|
{/* Masked sweep overlay on hover */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as React from 'react';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { TransitionLink } from '@/components/ui/TransitionLink';
|
import { TransitionLink } from '@/components/ui/TransitionLink';
|
||||||
import { Home, Layers, Star, Mail, Menu } from 'lucide-react';
|
import { Home, Layers, Star, Mail, Menu } from 'lucide-react';
|
||||||
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
import { m, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
export interface NavLink {
|
export interface NavLink {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
7
components/providers/DynamicFramerMotion.tsx
Normal file
7
components/providers/DynamicFramerMotion.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
export const DynamicFramerMotion = dynamic(
|
||||||
|
() => import('./FramerMotionProvider').then((mod) => mod.FramerMotionProvider),
|
||||||
|
{ ssr: true }
|
||||||
|
);
|
||||||
7
components/providers/DynamicInitialLoader.tsx
Normal file
7
components/providers/DynamicInitialLoader.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
export const DynamicInitialLoader = dynamic(
|
||||||
|
() => import('./InitialLoader').then(mod => mod.InitialLoader),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
11
components/providers/FramerMotionProvider.tsx
Normal file
11
components/providers/FramerMotionProvider.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
'use client';
|
||||||
|
import { LazyMotion } from 'framer-motion';
|
||||||
|
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
|
||||||
|
|
||||||
|
export function FramerMotionProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<LazyMotion features={loadFeatures} strict>
|
||||||
|
{children}
|
||||||
|
</LazyMotion>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
import { m, AnimatePresence } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
|
import { m, AnimatePresence } from 'framer-motion';
|
||||||
import { useTransition } from './TransitionProvider';
|
import { useTransition } from './TransitionProvider';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { LazyMotion } from 'framer-motion';
|
|
||||||
|
|
||||||
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
|
|
||||||
|
|
||||||
interface TransitionContextType {
|
interface TransitionContextType {
|
||||||
isTransitioning: boolean;
|
isTransitioning: boolean;
|
||||||
@@ -71,9 +68,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
|||||||
setTransitionMessage={setTransitionMessage}
|
setTransitionMessage={setTransitionMessage}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
<LazyMotion features={loadFeatures}>
|
{children}
|
||||||
{children}
|
|
||||||
</LazyMotion>
|
|
||||||
</TransitionContext.Provider>
|
</TransitionContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useMotionValue, useTransform, animate, useInView, m, LazyMotion, domAnimation } from 'framer-motion';
|
import { useMotionValue, useTransform, animate, useInView, m } from 'framer-motion';
|
||||||
|
|
||||||
interface AnimatedCounterProps {
|
interface AnimatedCounterProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useInView, m, LazyMotion, domAnimation } from 'framer-motion';
|
import { useInView, m } from 'framer-motion';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ layout: "fullBleed"
|
|||||||
showFairs={true}
|
showFairs={true}
|
||||||
showJobs={false}
|
showJobs={false}
|
||||||
fairs={[
|
fairs={[
|
||||||
{ name: 'Intersolar München', date: '23. - 25. Juni 2026', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' },
|
|
||||||
{ name: 'Windenergietage Linstow', date: '10. - 12. November 2026', type: 'Fachkongress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
|
{ name: 'Windenergietage Linstow', date: '10. - 12. November 2026', type: 'Fachkongress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
|
||||||
{ name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }
|
{ name: 'Werkstatt Kabel 2026', date: '24. - 25. November 2026', type: 'Fachtagung', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' },
|
||||||
|
{ name: 'Intersolar München', date: '8. - 10. Juni 2027', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ layout: "fullBleed"
|
|||||||
phone: "+49 1520 7230518",
|
phone: "+49 1520 7230518",
|
||||||
image: "/assets/photos/team/danny.jpg"
|
image: "/assets/photos/team/danny.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "kathrin-lemanski",
|
||||||
|
name: "Kathrin Lemanski",
|
||||||
|
position: "Kaufmännische Leitung",
|
||||||
|
email: "k.lemanski@e-tib.com",
|
||||||
|
phone: "+49 3561 6857733",
|
||||||
|
image: "/assets/photos/team/kathrin.jpg"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "dirk-schmidt",
|
id: "dirk-schmidt",
|
||||||
name: "Dirk Schmidt",
|
name: "Dirk Schmidt",
|
||||||
@@ -47,22 +55,6 @@ layout: "fullBleed"
|
|||||||
phone: "+49 171 3868167",
|
phone: "+49 171 3868167",
|
||||||
image: "/assets/photos/team/martin.jpg"
|
image: "/assets/photos/team/martin.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "kerstin-joseph",
|
|
||||||
name: "Kerstin Joseph",
|
|
||||||
position: "Sekretariat / Buchhaltung",
|
|
||||||
email: "k.joseph@e-tib.com",
|
|
||||||
phone: "+49 3561 6851692",
|
|
||||||
image: "/assets/photos/team/kerstin.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "kathrin-lemanski",
|
|
||||||
name: "Kathrin Lemanski",
|
|
||||||
position: "Sachbearbeitung",
|
|
||||||
email: "k.lemanski@e-tib.com",
|
|
||||||
phone: "+49 3561 6857733",
|
|
||||||
image: "/assets/photos/team/kathrin.jpg"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "sven-kirbs",
|
id: "sven-kirbs",
|
||||||
name: "Sven Kirbs",
|
name: "Sven Kirbs",
|
||||||
@@ -71,6 +63,21 @@ layout: "fullBleed"
|
|||||||
phone: "+49 151 29603652",
|
phone: "+49 151 29603652",
|
||||||
image: "/assets/photos/team/sven.jpg"
|
image: "/assets/photos/team/sven.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "kerstin-joseph",
|
||||||
|
name: "Kerstin Joseph",
|
||||||
|
position: "Buchhaltung / Controlling",
|
||||||
|
email: "k.joseph@e-tib.com",
|
||||||
|
phone: "+49 3561 6857733",
|
||||||
|
image: "/assets/photos/team/kerstin.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "katrin-haigold",
|
||||||
|
name: "Katrin Haigold",
|
||||||
|
position: "Buchhaltung / Personalwesen",
|
||||||
|
email: "k.haigold@e-tib.com",
|
||||||
|
phone: "+49 3561 6851692"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "maik-weichert",
|
id: "maik-weichert",
|
||||||
name: "Maik Weichert",
|
name: "Maik Weichert",
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ layout: "fullBleed"
|
|||||||
showFairs={true}
|
showFairs={true}
|
||||||
showJobs={false}
|
showJobs={false}
|
||||||
fairs={[
|
fairs={[
|
||||||
{ name: 'Intersolar Munich', date: 'June 23–25, 2026', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' },
|
|
||||||
{ name: 'Wind Energy Days Linstow', date: 'November 10–12, 2026', type: 'Specialist Congress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
|
{ name: 'Wind Energy Days Linstow', date: 'November 10–12, 2026', type: 'Specialist Congress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
|
||||||
{ name: 'Werkstatt Kabel 2026', date: 'November 24–25, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' }
|
{ name: 'Werkstatt Kabel 2026', date: 'November 24–25, 2026', type: 'Symposium', location: 'Wiesbaden', url: 'https://werkstatt-kabel.essociation.de/' },
|
||||||
|
{ name: 'Intersolar Munich', date: 'June 8–10, 2027', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ layout: "fullBleed"
|
|||||||
phone: "+49 1520 7230518",
|
phone: "+49 1520 7230518",
|
||||||
image: "/assets/photos/team/danny.jpg"
|
image: "/assets/photos/team/danny.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "kathrin-lemanski",
|
||||||
|
name: "Kathrin Lemanski",
|
||||||
|
position: "Commercial Management",
|
||||||
|
email: "k.lemanski@e-tib.com",
|
||||||
|
phone: "+49 3561 6857733",
|
||||||
|
image: "/assets/photos/team/kathrin.jpg"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "dirk-schmidt",
|
id: "dirk-schmidt",
|
||||||
name: "Dirk Schmidt",
|
name: "Dirk Schmidt",
|
||||||
@@ -47,22 +55,6 @@ layout: "fullBleed"
|
|||||||
phone: "+49 171 3868167",
|
phone: "+49 171 3868167",
|
||||||
image: "/assets/photos/team/martin.jpg"
|
image: "/assets/photos/team/martin.jpg"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "kerstin-joseph",
|
|
||||||
name: "Kerstin Joseph",
|
|
||||||
position: "Secretariat / Accounting",
|
|
||||||
email: "k.joseph@e-tib.com",
|
|
||||||
phone: "+49 3561 6851692",
|
|
||||||
image: "/assets/photos/team/kerstin.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "kathrin-lemanski",
|
|
||||||
name: "Kathrin Lemanski",
|
|
||||||
position: "Administration",
|
|
||||||
email: "k.lemanski@e-tib.com",
|
|
||||||
phone: "+49 3561 6857733",
|
|
||||||
image: "/assets/photos/team/kathrin.jpg"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "sven-kirbs",
|
id: "sven-kirbs",
|
||||||
name: "Sven Kirbs",
|
name: "Sven Kirbs",
|
||||||
@@ -71,6 +63,21 @@ layout: "fullBleed"
|
|||||||
phone: "+49 151 29603652",
|
phone: "+49 151 29603652",
|
||||||
image: "/assets/photos/team/sven.jpg"
|
image: "/assets/photos/team/sven.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "kerstin-joseph",
|
||||||
|
name: "Kerstin Joseph",
|
||||||
|
position: "Accounting / Controlling",
|
||||||
|
email: "k.joseph@e-tib.com",
|
||||||
|
phone: "+49 3561 6857733",
|
||||||
|
image: "/assets/photos/team/kerstin.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "katrin-haigold",
|
||||||
|
name: "Katrin Haigold",
|
||||||
|
position: "Accounting / Human Resources",
|
||||||
|
email: "k.haigold@e-tib.com",
|
||||||
|
phone: "+49 3561 6851692"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "maik-weichert",
|
id: "maik-weichert",
|
||||||
name: "Maik Weichert",
|
name: "Maik Weichert",
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ layout: "fullBleed"
|
|||||||
type: 'iso',
|
type: 'iso',
|
||||||
date: '05.12.2025',
|
date: '05.12.2025',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Commercial Liability Insurance',
|
||||||
|
description: 'RheinLand Insurance (€10M coverage)',
|
||||||
|
pdfUrl: '/assets/certificates/betriebshaftpflicht.pdf',
|
||||||
|
type: 'general',
|
||||||
|
date: '25.03.2026',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Exemption Certificate',
|
title: 'Exemption Certificate',
|
||||||
description: 'According to § 48 b EStG',
|
description: 'According to § 48 b EStG',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}"
|
||||||
|
|
||||||
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|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|assets|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
||||||
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
||||||
// from being included in the initial JS bundle.
|
// from being included in the initial JS bundle.
|
||||||
import * as Sentry from '@sentry/nextjs';
|
|
||||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
export {};
|
||||||
|
|||||||
11921
lh-desktop.json
Normal file
11921
lh-desktop.json
Normal file
File diff suppressed because one or more lines are too long
11867
lh-report.json
Normal file
11867
lh-report.json
Normal file
File diff suppressed because one or more lines are too long
11818
lh-standorte.json
Normal file
11818
lh-standorte.json
Normal file
File diff suppressed because one or more lines are too long
@@ -5,7 +5,9 @@ import { join } from 'path';
|
|||||||
* Loads the Inter fonts for use in Satori (Next.js OG Image generation).
|
* Loads the Inter fonts for use in Satori (Next.js OG Image generation).
|
||||||
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
|
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
|
||||||
*/
|
*/
|
||||||
|
let cachedFonts: any[] | null = null;
|
||||||
export async function getOgFonts() {
|
export async function getOgFonts() {
|
||||||
|
if (cachedFonts) return cachedFonts;
|
||||||
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff');
|
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.woff');
|
||||||
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff');
|
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.woff');
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ export async function getOgFonts() {
|
|||||||
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
|
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
cachedFonts = [
|
||||||
{
|
{
|
||||||
name: 'Inter',
|
name: 'Inter',
|
||||||
data: boldFont,
|
data: boldFont,
|
||||||
@@ -42,28 +44,35 @@ export async function getOgFonts() {
|
|||||||
style: 'normal' as const,
|
style: 'normal' as const,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
return cachedFonts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error);
|
console.error(`[OG] Failed to load fonts from ${process.cwd()}:`, error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cachedBackground: string | null = null;
|
||||||
export function getOgBackground() {
|
export function getOgBackground() {
|
||||||
|
if (cachedBackground) return cachedBackground;
|
||||||
try {
|
try {
|
||||||
const bgPath = join(process.cwd(), 'public/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg');
|
const bgPath = join(process.cwd(), 'public/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg');
|
||||||
const bgBase64 = readFileSync(bgPath, 'base64');
|
const bgBase64 = readFileSync(bgPath, 'base64');
|
||||||
return `data:image/jpeg;base64,${bgBase64}`;
|
cachedBackground = `data:image/jpeg;base64,${bgBase64}`;
|
||||||
|
return cachedBackground;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[OG] Failed to load background', err);
|
console.error('[OG] Failed to load background', err);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cachedLogo: string | null = null;
|
||||||
export function getOgLogo() {
|
export function getOgLogo() {
|
||||||
|
if (cachedLogo) return cachedLogo;
|
||||||
try {
|
try {
|
||||||
const logoPath = join(process.cwd(), 'public/assets/logo-white.png');
|
const logoPath = join(process.cwd(), 'public/assets/logo-white.png');
|
||||||
const logoBase64 = readFileSync(logoPath, 'base64');
|
const logoBase64 = readFileSync(logoPath, 'base64');
|
||||||
return `data:image/png;base64,${logoBase64}`;
|
cachedLogo = `data:image/png;base64,${logoBase64}`;
|
||||||
|
return cachedLogo;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[OG] Failed to load logo', err);
|
console.error('[OG] Failed to load logo', err);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function getAppServices(): AppServices {
|
|||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
dsn: config.errors.glitchtip.dsn,
|
dsn: config.errors.glitchtip.dsn,
|
||||||
tracesSampleRate: 0.1, // Default to 10% sampling
|
tracesSampleRate: 0.1, // Client-side we usually want lower sample rate
|
||||||
},
|
},
|
||||||
logger,
|
logger,
|
||||||
notifications,
|
notifications,
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
|
ignoreErrors: [
|
||||||
|
'Failed to find Server Action. This request might be from an older or newer deployment.',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Sentry;
|
return Sentry;
|
||||||
@@ -73,7 +76,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
||||||
|
|
||||||
await this.notifications.notify({
|
await this.notifications.notify({
|
||||||
title: '🔥 Critical Error Captured',
|
title: '🔥 [E-TIB] Critical Error Captured',
|
||||||
message: `Error: ${errorMessage}${contextStr}`,
|
message: `Error: ${errorMessage}${contextStr}`,
|
||||||
priority: 7,
|
priority: 7,
|
||||||
});
|
});
|
||||||
@@ -92,7 +95,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
(level === 'error' || level === 'fatal' || message.includes('Route Not Found'))
|
(level === 'error' || level === 'fatal' || message.includes('Route Not Found'))
|
||||||
) {
|
) {
|
||||||
await this.notifications.notify({
|
await this.notifications.notify({
|
||||||
title: level === 'warning' ? '⚠️ Warning Captured' : '🔥 Critical Message Captured',
|
title: level === 'warning' ? '⚠️ [E-TIB] Warning Captured' : '🔥 [E-TIB] Critical Message Captured',
|
||||||
message: message,
|
message: message,
|
||||||
priority: level === 'warning' ? 5 : 7,
|
priority: level === 'warning' ? 5 : 7,
|
||||||
});
|
});
|
||||||
|
|||||||
6
lint-results.json
Normal file
6
lint-results.json
Normal file
File diff suppressed because one or more lines are too long
@@ -280,7 +280,7 @@
|
|||||||
"TeamGrid": {
|
"TeamGrid": {
|
||||||
"badge": "Persönliche Beratung",
|
"badge": "Persönliche Beratung",
|
||||||
"title": "Ihre Ansprechpartner",
|
"title": "Ihre Ansprechpartner",
|
||||||
"subtitle": "Sprechen Sie direkt mit unseren Experten für Ihr regionales Projekt.",
|
"subtitle": "Sprechen Sie direkt mit unseren Experten für Ihr Projekt.",
|
||||||
"management": "Geschäftsführung",
|
"management": "Geschäftsführung",
|
||||||
"branchETIB": "E-TIB GmbH",
|
"branchETIB": "E-TIB GmbH",
|
||||||
"branchIng": "Ingenieurgesellschaft",
|
"branchIng": "Ingenieurgesellschaft",
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
"TeamGrid": {
|
"TeamGrid": {
|
||||||
"badge": "Personal Consultation",
|
"badge": "Personal Consultation",
|
||||||
"title": "Your Contacts",
|
"title": "Your Contacts",
|
||||||
"subtitle": "Speak directly with our experts for your regional project.",
|
"subtitle": "Speak directly with our experts for your project.",
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
"branchETIB": "E-TIB GmbH",
|
"branchETIB": "E-TIB GmbH",
|
||||||
"branchIng": "Engineering Company",
|
"branchIng": "Engineering Company",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import withMintelConfig from '@mintel/next-config';
|
import { baseNextConfig } from '@mintel/next-config';
|
||||||
import withBundleAnalyzer from '@next/bundle-analyzer';
|
import withBundleAnalyzer from '@next/bundle-analyzer';
|
||||||
import { withSentryConfig } from '@sentry/nextjs';
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ const nextConfig = {
|
|||||||
pagesBufferLength: 2,
|
pagesBufferLength: 2,
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizeCss: true,
|
inlineCss: true,
|
||||||
staleTimes: {
|
staleTimes: {
|
||||||
dynamic: 0,
|
dynamic: 0,
|
||||||
static: 30,
|
static: 30,
|
||||||
@@ -24,7 +24,6 @@ const nextConfig = {
|
|||||||
'lucide-react',
|
'lucide-react',
|
||||||
'framer-motion',
|
'framer-motion',
|
||||||
'@/components/ui',
|
'@/components/ui',
|
||||||
'@sentry/nextjs',
|
|
||||||
'react-hook-form',
|
'react-hook-form',
|
||||||
'zod',
|
'zod',
|
||||||
'date-fns',
|
'date-fns',
|
||||||
@@ -55,6 +54,8 @@ const nextConfig = {
|
|||||||
...config.resolve.alias,
|
...config.resolve.alias,
|
||||||
'next/dist/build/polyfills/polyfill-module': false,
|
'next/dist/build/polyfills/polyfill-module': false,
|
||||||
'next/dist/build/polyfills/polyfill-nomodule': false,
|
'next/dist/build/polyfills/polyfill-nomodule': false,
|
||||||
|
'core-js': false,
|
||||||
|
'regenerator-runtime': false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,14 +549,58 @@ const withAnalyzer = withBundleAnalyzer({
|
|||||||
enabled: process.env.ANALYZE === 'true',
|
enabled: process.env.ANALYZE === 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
// withMintelConfig internally sets output:'standalone' and wraps with withSentryConfig.
|
|
||||||
// In dev mode, standalone triggers @vercel/nft file tracing on every compile through
|
// In dev mode, standalone triggers @vercel/nft file tracing on every compile through
|
||||||
// VirtioFS, which hammers CPU. Strip it for dev.
|
// VirtioFS, which hammers CPU. Strip it for dev.
|
||||||
const withMDX = createMDX({});
|
const withMDX = createMDX({});
|
||||||
let resolvedConfig = withMDX(withAnalyzer(withMintelConfig(nextConfig)));
|
let resolvedConfig = withMDX(withAnalyzer(nextConfig));
|
||||||
|
|
||||||
|
// Apply next-intl manually (bypassing withMintelConfig to control Sentry)
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
resolvedConfig = withNextIntl(resolvedConfig);
|
||||||
|
|
||||||
|
// Removed withSentryConfig completely to eliminate the 170KB client-side bloat and Array.from legacy polyfill.
|
||||||
|
// Server-side instrumentation is still handled manually in instrumentation.ts.
|
||||||
|
|
||||||
|
// Manually merge baseNextConfig properties
|
||||||
|
const finalConfig = {
|
||||||
|
...baseNextConfig,
|
||||||
|
...resolvedConfig,
|
||||||
|
images: {
|
||||||
|
...baseNextConfig.images,
|
||||||
|
...(resolvedConfig.images || {}),
|
||||||
|
remotePatterns: [
|
||||||
|
...(baseNextConfig.images?.remotePatterns || []),
|
||||||
|
...(resolvedConfig.images?.remotePatterns || [])
|
||||||
|
]
|
||||||
|
},
|
||||||
|
rewrites: async () => {
|
||||||
|
let baseRewrites = [];
|
||||||
|
if (typeof baseNextConfig.rewrites === 'function') {
|
||||||
|
baseRewrites = await baseNextConfig.rewrites();
|
||||||
|
}
|
||||||
|
let configRewrites = { beforeFiles: [], fallback: [], afterFiles: [] };
|
||||||
|
if (typeof resolvedConfig.rewrites === 'function') {
|
||||||
|
const res = await resolvedConfig.rewrites();
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
configRewrites.fallback = res;
|
||||||
|
} else {
|
||||||
|
configRewrites = res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
beforeFiles: configRewrites.beforeFiles || [],
|
||||||
|
fallback: [
|
||||||
|
...(configRewrites.fallback || []),
|
||||||
|
...baseRewrites
|
||||||
|
],
|
||||||
|
afterFiles: configRewrites.afterFiles || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isProd) {
|
if (!isProd) {
|
||||||
delete resolvedConfig.output;
|
delete finalConfig.output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default resolvedConfig;
|
export default finalConfig;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.3.1",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
@@ -139,7 +140,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.2.59",
|
"version": "2.4.46",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
@@ -150,7 +151,8 @@
|
|||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"minimatch": ">=10.2.2"
|
"minimatch": ">=10.2.2",
|
||||||
|
"framer-motion": "^12.38.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|||||||
62
pnpm-lock.yaml
generated
62
pnpm-lock.yaml
generated
@@ -7,6 +7,7 @@ settings:
|
|||||||
overrides:
|
overrides:
|
||||||
next: 16.1.6
|
next: 16.1.6
|
||||||
minimatch: '>=10.2.2'
|
minimatch: '>=10.2.2'
|
||||||
|
framer-motion: ^12.38.0
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ importers:
|
|||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.34.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
graphql:
|
graphql:
|
||||||
specifier: ^16.12.0
|
specifier: ^16.12.0
|
||||||
@@ -211,6 +212,9 @@ importers:
|
|||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.1.4
|
specifier: ^5.1.4
|
||||||
version: 5.2.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
version: 5.2.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
'@vitejs/plugin-react-swc':
|
||||||
|
specifier: ^4.3.1
|
||||||
|
version: 4.3.1(@swc/helpers@0.5.20)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
'@vitest/ui':
|
'@vitest/ui':
|
||||||
specifier: ^4.0.16
|
specifier: ^4.0.16
|
||||||
version: 4.1.2(vitest@4.1.2)
|
version: 4.1.2(vitest@4.1.2)
|
||||||
@@ -2351,6 +2355,9 @@ packages:
|
|||||||
'@rolldown/pluginutils@1.0.0-rc.3':
|
'@rolldown/pluginutils@1.0.0-rc.3':
|
||||||
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1':
|
||||||
|
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||||
|
|
||||||
'@rollup/plugin-commonjs@28.0.1':
|
'@rollup/plugin-commonjs@28.0.1':
|
||||||
resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==}
|
resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==}
|
||||||
engines: {node: '>=16.0.0 || 14 >= 14.17'}
|
engines: {node: '>=16.0.0 || 14 >= 14.17'}
|
||||||
@@ -3285,6 +3292,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>= 16.8.0'
|
react: '>= 16.8.0'
|
||||||
|
|
||||||
|
'@vitejs/plugin-react-swc@4.3.1':
|
||||||
|
resolution: {integrity: sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^4 || ^5 || ^6 || ^7 || ^8
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.2.0':
|
'@vitejs/plugin-react@5.2.0':
|
||||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -4843,20 +4856,6 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
framer-motion@11.18.2:
|
|
||||||
resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==}
|
|
||||||
peerDependencies:
|
|
||||||
'@emotion/is-prop-valid': '*'
|
|
||||||
react: ^18.0.0 || ^19.0.0
|
|
||||||
react-dom: ^18.0.0 || ^19.0.0
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@emotion/is-prop-valid':
|
|
||||||
optional: true
|
|
||||||
react:
|
|
||||||
optional: true
|
|
||||||
react-dom:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
framer-motion@12.38.0:
|
framer-motion@12.38.0:
|
||||||
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
|
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -6142,15 +6141,9 @@ packages:
|
|||||||
module-details-from-path@1.0.4:
|
module-details-from-path@1.0.4:
|
||||||
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
|
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
|
||||||
|
|
||||||
motion-dom@11.18.1:
|
|
||||||
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
|
|
||||||
|
|
||||||
motion-dom@12.38.0:
|
motion-dom@12.38.0:
|
||||||
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
||||||
|
|
||||||
motion-utils@11.18.1:
|
|
||||||
resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==}
|
|
||||||
|
|
||||||
motion-utils@12.36.0:
|
motion-utils@12.36.0:
|
||||||
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
|
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
|
||||||
|
|
||||||
@@ -9246,7 +9239,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@medv/finder': 4.0.2
|
'@medv/finder': 4.0.2
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
framer-motion: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
html2canvas: 1.4.1
|
html2canvas: 1.4.1
|
||||||
lucide-react: 0.441.0(react@19.2.4)
|
lucide-react: 0.441.0(react@19.2.4)
|
||||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)
|
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)
|
||||||
@@ -10136,6 +10129,8 @@ snapshots:
|
|||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1': {}
|
||||||
|
|
||||||
'@rollup/plugin-commonjs@28.0.1(rollup@4.60.1)':
|
'@rollup/plugin-commonjs@28.0.1(rollup@4.60.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||||
@@ -11047,6 +11042,14 @@ snapshots:
|
|||||||
'@use-gesture/core': 10.3.1
|
'@use-gesture/core': 10.3.1
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
|
'@vitejs/plugin-react-swc@4.3.1(@swc/helpers@0.5.20)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
|
||||||
|
dependencies:
|
||||||
|
'@rolldown/pluginutils': 1.0.1
|
||||||
|
'@swc/core': 1.15.21(@swc/helpers@0.5.20)
|
||||||
|
vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@swc/helpers'
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.2.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
|
'@vitejs/plugin-react@5.2.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@@ -12941,15 +12944,6 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
framer-motion@11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
|
||||||
dependencies:
|
|
||||||
motion-dom: 11.18.1
|
|
||||||
motion-utils: 11.18.1
|
|
||||||
tslib: 2.8.1
|
|
||||||
optionalDependencies:
|
|
||||||
react: 19.2.4
|
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
|
||||||
|
|
||||||
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-dom: 12.38.0
|
motion-dom: 12.38.0
|
||||||
@@ -14577,16 +14571,10 @@ snapshots:
|
|||||||
|
|
||||||
module-details-from-path@1.0.4: {}
|
module-details-from-path@1.0.4: {}
|
||||||
|
|
||||||
motion-dom@11.18.1:
|
|
||||||
dependencies:
|
|
||||||
motion-utils: 11.18.1
|
|
||||||
|
|
||||||
motion-dom@12.38.0:
|
motion-dom@12.38.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-utils: 12.36.0
|
motion-utils: 12.36.0
|
||||||
|
|
||||||
motion-utils@11.18.1: {}
|
|
||||||
|
|
||||||
motion-utils@12.36.0: {}
|
motion-utils@12.36.0: {}
|
||||||
|
|
||||||
mri@1.2.0: {}
|
mri@1.2.0: {}
|
||||||
|
|||||||
39
psi-standorte.json
Normal file
39
psi-standorte.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": 429,
|
||||||
|
"message": "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'pagespeedonline.googleapis.com' for consumer 'project_number:583797351490'.",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'pagespeedonline.googleapis.com' for consumer 'project_number:583797351490'.",
|
||||||
|
"domain": "global",
|
||||||
|
"reason": "rateLimitExceeded"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "RESOURCE_EXHAUSTED",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||||
|
"reason": "RATE_LIMIT_EXCEEDED",
|
||||||
|
"domain": "googleapis.com",
|
||||||
|
"metadata": {
|
||||||
|
"service": "pagespeedonline.googleapis.com",
|
||||||
|
"quota_location": "global",
|
||||||
|
"quota_limit_value": "0",
|
||||||
|
"consumer": "projects/583797351490",
|
||||||
|
"quota_metric": "pagespeedonline.googleapis.com/default",
|
||||||
|
"quota_limit": "defaultPerDayPerProject",
|
||||||
|
"quota_unit": "1/d/{project}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "type.googleapis.com/google.rpc.Help",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"description": "Request a higher quota limit.",
|
||||||
|
"url": "https://cloud.google.com/docs/quotas/help/request_increase"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/assets/certificates/betriebshaftpflicht.pdf
Normal file
BIN
public/assets/certificates/betriebshaftpflicht.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
36
run-lh.cjs
Normal file
36
run-lh.cjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
console.log("Running Lighthouse...");
|
||||||
|
try {
|
||||||
|
execSync('npx lighthouse "https://e-tib.com/de/standorte?v=2" --chrome-flags="--headless --no-sandbox" --output json --output-path ./lh-report.json', { stdio: 'inherit' });
|
||||||
|
} catch(e) {
|
||||||
|
console.log("Lighthouse run failed", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync('./lh-report.json', 'utf8'));
|
||||||
|
const score = data.categories.performance.score * 100;
|
||||||
|
console.log("\n==================================");
|
||||||
|
console.log(`PERFORMANCE SCORE: ${score}`);
|
||||||
|
console.log("==================================\n");
|
||||||
|
|
||||||
|
console.log("FAILING METRICS (Score < 1.0):");
|
||||||
|
Object.values(data.audits).filter(a => a.score !== null && a.score < 1).forEach(a => {
|
||||||
|
console.log(`- ${a.id}: ${a.score} (${a.displayValue || 'No display value'})`);
|
||||||
|
if (a.details && a.details.items && a.details.items.length > 0) {
|
||||||
|
if (a.id === 'image-delivery-insight' || a.id === 'unused-javascript' || a.id === 'modern-image-formats' || a.id === 'uses-responsive-images') {
|
||||||
|
console.log(" Items:");
|
||||||
|
a.details.items.forEach(item => {
|
||||||
|
console.log(` URL: ${item.url || item.node?.nodeLabel || 'unknown'}`);
|
||||||
|
if (item.wastedBytes) console.log(` Wasted KB: ${(item.wastedBytes / 1024).toFixed(2)}`);
|
||||||
|
if (item.wastedMs) console.log(` Wasted Ms: ${item.wastedMs}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nLCP BREAKDOWN:");
|
||||||
|
const lcpElement = data.audits['largest-contentful-paint-element'];
|
||||||
|
if (lcpElement && lcpElement.details && lcpElement.details.items) {
|
||||||
|
console.log("LCP Element:", lcpElement.details.items[0].node.snippet);
|
||||||
|
}
|
||||||
36
run-lh.js
Normal file
36
run-lh.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
console.log("Running Lighthouse...");
|
||||||
|
try {
|
||||||
|
execSync('npx lighthouse "https://e-tib.com/de/standorte?v=2" --chrome-flags="--headless --no-sandbox" --output json --output-path ./lh-report.json', { stdio: 'inherit' });
|
||||||
|
} catch(e) {
|
||||||
|
console.log("Lighthouse run failed", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync('./lh-report.json', 'utf8'));
|
||||||
|
const score = data.categories.performance.score * 100;
|
||||||
|
console.log("\n==================================");
|
||||||
|
console.log(`PERFORMANCE SCORE: ${score}`);
|
||||||
|
console.log("==================================\n");
|
||||||
|
|
||||||
|
console.log("FAILING METRICS (Score < 1.0):");
|
||||||
|
Object.values(data.audits).filter(a => a.score !== null && a.score < 1).forEach(a => {
|
||||||
|
console.log(`- ${a.id}: ${a.score} (${a.displayValue || 'No display value'})`);
|
||||||
|
if (a.details && a.details.items && a.details.items.length > 0) {
|
||||||
|
if (a.id === 'image-delivery-insight' || a.id === 'unused-javascript' || a.id === 'modern-image-formats' || a.id === 'uses-responsive-images') {
|
||||||
|
console.log(" Items:");
|
||||||
|
a.details.items.forEach(item => {
|
||||||
|
console.log(` URL: ${item.url || item.node?.nodeLabel || 'unknown'}`);
|
||||||
|
if (item.wastedBytes) console.log(` Wasted KB: ${item.wastedBytes / 1024}`);
|
||||||
|
if (item.wastedMs) console.log(` Wasted Ms: ${item.wastedMs}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nLCP BREAKDOWN:");
|
||||||
|
const lcpElement = data.audits['largest-contentful-paint-element'];
|
||||||
|
if (lcpElement && lcpElement.details && lcpElement.details.items) {
|
||||||
|
console.log("LCP Element:", lcpElement.details.items[0].node.snippet);
|
||||||
|
}
|
||||||
@@ -6,13 +6,14 @@ module.exports = async (browser, context) => {
|
|||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
// Using LHCI_URL or TARGET_URL if available
|
// Using LHCI_URL or TARGET_URL if available
|
||||||
const targetUrl =
|
const targetUrl =
|
||||||
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.klz-cables.com';
|
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.e-tib.com';
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie for ${new URL(targetUrl).hostname}...`);
|
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie (${authCookieName}) for ${new URL(targetUrl).hostname}...`);
|
||||||
|
|
||||||
await page.setCookie({
|
await page.setCookie({
|
||||||
name: 'klz_gatekeeper_session',
|
name: authCookieName,
|
||||||
value: gatekeeperPassword,
|
value: gatekeeperPassword,
|
||||||
domain: new URL(targetUrl).hostname,
|
domain: new URL(targetUrl).hostname,
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const targetUrl =
|
|||||||
'http://localhost:3000';
|
'http://localhost:3000';
|
||||||
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
|
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
|
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
|
||||||
@@ -32,7 +33,7 @@ async function main() {
|
|||||||
// We might need to bypass gatekeeper for the sitemap fetch too
|
// We might need to bypass gatekeeper for the sitemap fetch too
|
||||||
const response = await axios.get(sitemapUrl, {
|
const response = await axios.get(sitemapUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
Cookie: `${authCookieName}=${gatekeeperPassword}`,
|
||||||
},
|
},
|
||||||
validateStatus: (status) => status < 400,
|
validateStatus: (status) => status < 400,
|
||||||
});
|
});
|
||||||
@@ -77,7 +78,7 @@ async function main() {
|
|||||||
// Handle authentication for staging/testing
|
// Handle authentication for staging/testing
|
||||||
// Lighthouse can set cookies via --collect.settings.extraHeaders
|
// Lighthouse can set cookies via --collect.settings.extraHeaders
|
||||||
const extraHeaders = JSON.stringify({
|
const extraHeaders = JSON.stringify({
|
||||||
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
Cookie: `${authCookieName}=${gatekeeperPassword}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect Chrome path from Puppeteer installation if not provided
|
// Detect Chrome path from Puppeteer installation if not provided
|
||||||
@@ -147,7 +148,7 @@ async function main() {
|
|||||||
execSync(lhciCommand, {
|
execSync(lhciCommand, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, CHROME_PATH: chromePath },
|
env: { ...process.env, CHROME_PATH: chromePath, TARGET_URL: targetUrl },
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
|
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ while IFS= read -r run; do
|
|||||||
elif [ "$CURRENT_PRIO" -lt "$RUN_PRIO" ]; then
|
elif [ "$CURRENT_PRIO" -lt "$RUN_PRIO" ]; then
|
||||||
echo "Current workflow has LOWER priority ($CURRENT_PRIO < $RUN_PRIO). Aborting self."
|
echo "Current workflow has LOWER priority ($CURRENT_PRIO < $RUN_PRIO). Aborting self."
|
||||||
curl -s -X POST -H "Authorization: token $GITEA_PAT" "$API_BASE/$CURRENT_RUN_ID/cancel"
|
curl -s -X POST -H "Authorization: token $GITEA_PAT" "$API_BASE/$CURRENT_RUN_ID/cancel"
|
||||||
exit 1
|
echo "Waiting for Gitea to process cancellation..."
|
||||||
|
sleep 15
|
||||||
|
exit 0
|
||||||
else
|
else
|
||||||
# Equal priority: Newer wins
|
# Equal priority: Newer wins
|
||||||
if [ "$CURRENT_RUN_ID" -gt "$RUN_ID" ]; then
|
if [ "$CURRENT_RUN_ID" -gt "$RUN_ID" ]; then
|
||||||
@@ -79,7 +81,9 @@ while IFS= read -r run; do
|
|||||||
else
|
else
|
||||||
echo "Equal priority, but current is older. Aborting self."
|
echo "Equal priority, but current is older. Aborting self."
|
||||||
curl -s -X POST -H "Authorization: token $GITEA_PAT" "$API_BASE/$CURRENT_RUN_ID/cancel"
|
curl -s -X POST -H "Authorization: token $GITEA_PAT" "$API_BASE/$CURRENT_RUN_ID/cancel"
|
||||||
exit 1
|
echo "Waiting for Gitea to process cancellation..."
|
||||||
|
sleep 15
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done <<< "$RUNS"
|
done <<< "$RUNS"
|
||||||
|
|||||||
9
skip_branch_script.sh
Normal file
9
skip_branch_script.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
if [[ "${{ github.ref_type }}" == "branch" && "${{ github.ref_name }}" != "main" && "${{ github.ref_name }}" != "master" ]]; then
|
||||||
|
# Fetch tags from remote for this specific commit to check if it's being tagged simultaneously
|
||||||
|
TAGS=$(git ls-remote --tags origin | grep "${{ github.sha }}" || true)
|
||||||
|
if [[ -n "$TAGS" ]]; then
|
||||||
|
echo "Dieser Commit hat einen Tag! Überspringe Preview-Deployment, damit der Tag-Deploy Vorrang hat."
|
||||||
|
echo "target=skip" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -96,26 +96,7 @@ module.exports = {
|
|||||||
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||||
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||||
},
|
},
|
||||||
container: {
|
|
||||||
center: true,
|
|
||||||
padding: {
|
|
||||||
DEFAULT: '1rem',
|
|
||||||
sm: '1.5rem',
|
|
||||||
md: '2rem',
|
|
||||||
lg: '2.5rem',
|
|
||||||
xl: '3rem',
|
|
||||||
'2xl': '4rem',
|
|
||||||
},
|
|
||||||
screens: {
|
|
||||||
xs: '475px',
|
|
||||||
sm: '640px',
|
|
||||||
md: '768px',
|
|
||||||
lg: '1024px',
|
|
||||||
xl: '1280px',
|
|
||||||
'2xl': '1400px',
|
|
||||||
'3xl': '1600px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
maxWidth: {
|
maxWidth: {
|
||||||
'xs': '20rem',
|
'xs': '20rem',
|
||||||
'sm': '24rem',
|
'sm': '24rem',
|
||||||
|
|||||||
28
tests/certificates.test.ts
Normal file
28
tests/certificates.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { defaultCertificates } from '../components/blocks/CertificatesBlock';
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
describe('Certificates & Insurance Integration (DE + EN)', () => {
|
||||||
|
it('includes the new RheinLand Betriebshaftpflichtversicherung in defaultCertificates (DE)', () => {
|
||||||
|
const haftpflichtCert = defaultCertificates.find(
|
||||||
|
(cert) => cert.title === 'Betriebshaftpflichtversicherung'
|
||||||
|
);
|
||||||
|
expect(haftpflichtCert).toBeDefined();
|
||||||
|
expect(haftpflichtCert?.pdfUrl).toBe('/assets/certificates/betriebshaftpflicht.pdf');
|
||||||
|
expect(haftpflichtCert?.date).toBe('25.03.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies that content/en/zertifikate.mdx includes Commercial Liability Insurance (EN)', () => {
|
||||||
|
const enMdx = fs.readFileSync(path.join(__dirname, '../content/en/zertifikate.mdx'), 'utf-8');
|
||||||
|
expect(enMdx).toContain("title: 'Commercial Liability Insurance'");
|
||||||
|
expect(enMdx).toContain("pdfUrl: '/assets/certificates/betriebshaftpflicht.pdf'");
|
||||||
|
expect(enMdx).toContain("date: '25.03.2026'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies that the PDF asset exists in public/assets/certificates/betriebshaftpflicht.pdf', () => {
|
||||||
|
const filePath = path.join(__dirname, '../public/assets/certificates/betriebshaftpflicht.pdf');
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
tests/create-services.test.ts
Normal file
26
tests/create-services.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { getAppServices } from '../lib/services/create-services';
|
||||||
|
import { GlitchtipErrorReportingService } from '../lib/services/errors/glitchtip-error-reporting-service';
|
||||||
|
|
||||||
|
// Mock config to ensure sentry is enabled
|
||||||
|
vi.mock('../lib/config', () => ({
|
||||||
|
config: {
|
||||||
|
analytics: { umami: { enabled: false } },
|
||||||
|
errors: { glitchtip: { enabled: true, dsn: 'https://test@glitchtip.infra.mintel.me/5' } },
|
||||||
|
logging: { level: 'info' },
|
||||||
|
notifications: { gotify: { enabled: false } },
|
||||||
|
},
|
||||||
|
getMaskedConfig: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AppServices (Client)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset singleton
|
||||||
|
globalThis.__appServices = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should instantiate GlitchtipErrorReportingService when enabled', () => {
|
||||||
|
const services = getAppServices();
|
||||||
|
expect(services.errors).toBeInstanceOf(GlitchtipErrorReportingService);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ describe('Messen Content and Translations', () => {
|
|||||||
|
|
||||||
// 1. Intersolar München
|
// 1. Intersolar München
|
||||||
expect(content).toContain("name: 'Intersolar München'");
|
expect(content).toContain("name: 'Intersolar München'");
|
||||||
expect(content).toContain("date: '23. - 25. Juni 2026'");
|
expect(content).toContain("date: '8. - 10. Juni 2027'");
|
||||||
expect(content).toContain("type: 'Messe'");
|
expect(content).toContain("type: 'Messe'");
|
||||||
expect(content).toContain("location: 'München'");
|
expect(content).toContain("location: 'München'");
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ describe('Messen Content and Translations', () => {
|
|||||||
|
|
||||||
// 1. Intersolar Munich
|
// 1. Intersolar Munich
|
||||||
expect(content).toContain("name: 'Intersolar Munich'");
|
expect(content).toContain("name: 'Intersolar Munich'");
|
||||||
expect(content).toContain("date: 'June 23–25, 2026'");
|
expect(content).toContain("date: 'June 8–10, 2027'");
|
||||||
expect(content).toContain("type: 'Fair'");
|
expect(content).toContain("type: 'Fair'");
|
||||||
expect(content).toContain("location: 'Munich'");
|
expect(content).toContain("location: 'Munich'");
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user