Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c2bfdc7e7c | |||
| 5625428113 | |||
| 8f9a37466c | |||
| 803ba997df | |||
| 4985ae0315 | |||
| 5c93a51db9 | |||
| 70a312052c | |||
| 0ccc408a8d | |||
| f9f0970f3f | |||
| f463e854fd | |||
| 81dd2c95ed | |||
| ecebe63084 | |||
| ba4d21195c | |||
| c74c786988 | |||
| 4fd7160967 | |||
| 04a2bb0ebc | |||
| a52c35a224 | |||
| 343b484c6e | |||
| a6a8dbd134 | |||
| 8f2509c3fc | |||
| 41a3c10cf8 | |||
| 103e4e63ed | |||
| 34860cab18 | |||
| 5105f449b5 | |||
| 1d07e47e5d | |||
| 3d48359768 | |||
| d9c2ea1ede | |||
| 76798e4704 | |||
| d014c875fc | |||
| e4a20b08b9 | |||
| 7b02750d50 | |||
| 992a07f54a | |||
| 490f216502 | |||
| 3674e21fda | |||
| 298b81713c | |||
| 3b438de94d | |||
| aff47de037 | |||
| 85d366cfe2 |
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,37 +47,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "=== System Disk Usage ==="
|
echo "=== System Disk Usage ==="
|
||||||
df -h || true
|
df -h || true
|
||||||
echo "=== Tool Cache Usage ==="
|
echo "Purging old tool caches skipped to prevent runner corruption."
|
||||||
du -sh /opt/hostedtoolcache/* || true
|
|
||||||
du -sh /opt/hostedtoolcache/.[!.]* || true
|
|
||||||
echo "Purging old tool caches..."
|
|
||||||
rm -rf /opt/hostedtoolcache/* || true
|
|
||||||
rm -rf /opt/hostedtoolcache/.[!.]* || true
|
|
||||||
echo "=== Host Disk Usage ==="
|
|
||||||
docker run --rm -v /:/host-root alpine df -h || true
|
|
||||||
echo "=== Host Inode Usage ==="
|
|
||||||
docker run --rm -v /:/host-root alpine df -i || true
|
|
||||||
echo "=== Running Host Containers ==="
|
echo "=== Running Host Containers ==="
|
||||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
|
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
|
||||||
echo "=== Host Docker Info ==="
|
echo "=== Host Docker Info ==="
|
||||||
docker info || true
|
docker info || true
|
||||||
echo "Pruning host Buildkit builder caches aggressively..."
|
echo "=== System Disk Usage After Check ==="
|
||||||
for container in $(docker ps --filter "name=buildx_buildkit_builder" --format "{{.Names}}"); do
|
|
||||||
echo "Pruning Buildkit inside container: $container"
|
|
||||||
docker exec -i "$container" buildctl prune --all || true
|
|
||||||
done
|
|
||||||
echo "Removing buildkit builder containers to clear overlay storage..."
|
|
||||||
containers=$(docker ps -a --filter "name=buildx_buildkit_builder" -q)
|
|
||||||
if [ -n "$containers" ]; then
|
|
||||||
docker rm -f $containers || true
|
|
||||||
fi
|
|
||||||
echo "=== Truncating host Docker container log files ==="
|
|
||||||
docker run --rm -v /:/host-root alpine sh -c "truncate -s 0 /host-root/mnt/HC_Volume_104796416/docker/containers/*/*.log" || true
|
|
||||||
echo "Purging old build layers, unused volumes and images..."
|
|
||||||
docker builder prune -a -f || true
|
|
||||||
docker volume prune -f || true
|
|
||||||
docker system prune -a -f --volumes || true
|
|
||||||
echo "=== System Disk Usage After Prune ==="
|
|
||||||
df -h || true
|
df -h || true
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -112,6 +88,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
TARGET="branch"
|
TARGET="branch"
|
||||||
|
|
||||||
|
# Check if this exact commit has a tag. If so, skip the branch deploy
|
||||||
|
# because the tag pipeline will handle the production/staging deploy.
|
||||||
|
git fetch --tags --quiet || true
|
||||||
|
if git describe --tags --exact-match HEAD >/dev/null 2>&1; then
|
||||||
|
echo "Commit has a tag. Skipping branch deployment to favor tag deployment."
|
||||||
|
TARGET="skip"
|
||||||
|
fi
|
||||||
|
|
||||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.branch-${SLUG}"
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
@@ -218,6 +203,13 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🚦 Priority Gate Check
|
||||||
|
env:
|
||||||
|
GITEA_PAT: ${{ secrets.GITEA_PAT }}
|
||||||
|
CURRENT_RUN_ID: ${{ github.run_id }}
|
||||||
|
CURRENT_TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
|
run: bash scripts/priority-gate.sh
|
||||||
|
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
id: auth
|
id: auth
|
||||||
env:
|
env:
|
||||||
@@ -297,6 +289,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🚦 Priority Gate Check
|
||||||
|
env:
|
||||||
|
GITEA_PAT: ${{ secrets.GITEA_PAT }}
|
||||||
|
CURRENT_RUN_ID: ${{ github.run_id }}
|
||||||
|
CURRENT_TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
|
run: bash scripts/priority-gate.sh
|
||||||
- name: 📝 Generate Environment
|
- name: 📝 Generate Environment
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -334,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
@@ -49,6 +49,7 @@ const mdxComponents = {
|
|||||||
JsonLd,
|
JsonLd,
|
||||||
Button,
|
Button,
|
||||||
Badge,
|
Badge,
|
||||||
|
Heading,
|
||||||
AnimatedCounter,
|
AnimatedCounter,
|
||||||
GrowthChart,
|
GrowthChart,
|
||||||
DeepDrillAnimation,
|
DeepDrillAnimation,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
<div className="container mx-auto 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 +192,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content Area with Sticky Narrative Layout */}
|
{/* Main Content Area with Sticky Narrative Layout */}
|
||||||
<div className="container mx-auto 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,10 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { getAppServices } from '@/lib/services/create-services';
|
|
||||||
import { Button, Heading } from '@/components/ui';
|
import { Button, Heading } from '@/components/ui';
|
||||||
import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react';
|
import { Terminal, Activity, AlertTriangle, RefreshCw, Home, ShieldAlert } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
@@ -26,9 +25,7 @@ export default function Error({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const services = getAppServices();
|
console.error('Application error caught by boundary', {
|
||||||
services.errors.captureException(error);
|
|
||||||
services.logger.error('Application error caught by boundary', {
|
|
||||||
message: error?.message || 'Unknown error',
|
message: error?.message || 'Unknown error',
|
||||||
stack: error?.stack,
|
stack: error?.stack,
|
||||||
digest: error?.digest,
|
digest: error?.digest,
|
||||||
@@ -65,7 +62,7 @@ export default function Error({
|
|||||||
<div className="relative z-10 w-full max-w-4xl mx-auto flex flex-col items-center">
|
<div className="relative z-10 w-full max-w-4xl mx-auto flex flex-col items-center">
|
||||||
{/* The "Broken" Machine Visual */}
|
{/* The "Broken" Machine Visual */}
|
||||||
<div className="relative mb-12">
|
<div className="relative mb-12">
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="relative"
|
className="relative"
|
||||||
@@ -81,7 +78,7 @@ export default function Error({
|
|||||||
|
|
||||||
{/* Warning Overlay */}
|
{/* Warning Overlay */}
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center">
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center">
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{
|
animate={{
|
||||||
opacity: [0.4, 1, 0.4],
|
opacity: [0.4, 1, 0.4],
|
||||||
scale: [0.95, 1.05, 0.95]
|
scale: [0.95, 1.05, 0.95]
|
||||||
@@ -90,9 +87,9 @@ export default function Error({
|
|||||||
className="p-4 rounded-2xl bg-red-500/20 border border-red-500/40 backdrop-blur-md shadow-[0_0_30px_rgba(239,68,68,0.3)]"
|
className="p-4 rounded-2xl bg-red-500/20 border border-red-500/40 backdrop-blur-md shadow-[0_0_30px_rgba(239,68,68,0.3)]"
|
||||||
>
|
>
|
||||||
<ShieldAlert className="w-12 h-12 text-red-500" />
|
<ShieldAlert className="w-12 h-12 text-red-500" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 w-full items-start">
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 w-full items-start">
|
||||||
|
|||||||
@@ -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,14 @@ import { mapFileSlugToTranslated } from '@/lib/slugs';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
import { TransitionProvider } from '@/components/providers/TransitionProvider';
|
import { TransitionProvider } from '@/components/providers/TransitionProvider';
|
||||||
import { PageTransitionShutter } from '@/components/providers/PageTransitionShutter';
|
|
||||||
import { InitialLoader } from '@/components/providers/InitialLoader';
|
import { DynamicInitialLoader as InitialLoader } from '@/components/providers/DynamicInitialLoader';
|
||||||
import { CorporateBackground } from '@/components/decorations/CorporateBackground';
|
import { DynamicMobileBottomNav as MobileBottomNav } from '@/components/layout/DynamicMobileBottomNav';
|
||||||
|
|
||||||
|
import { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground';
|
||||||
|
import { DynamicPageTransitionShutter as PageTransitionShutter } from '@/components/providers/DynamicPageTransitionShutter';
|
||||||
|
import { DynamicFramerMotion as FramerMotionProvider } from '@/components/providers/DynamicFramerMotion';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
@@ -32,7 +36,8 @@ export async function generateMetadata(props: {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { locale } = params;
|
const { locale } = params;
|
||||||
|
|
||||||
const baseUrl = process.env.CI ? 'http://etib.localhost' : SITE_URL;
|
const baseUrl = SITE_URL;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: {
|
title: {
|
||||||
template: '%s | E-TIB',
|
template: '%s | E-TIB',
|
||||||
@@ -42,10 +47,11 @@ export async function generateMetadata(props: {
|
|||||||
metadataBase: new URL(baseUrl),
|
metadataBase: new URL(baseUrl),
|
||||||
manifest: '/manifest.webmanifest',
|
manifest: '/manifest.webmanifest',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${baseUrl}/${locale}`,
|
canonical: `/${locale}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${baseUrl}/de`,
|
'de': `/de`,
|
||||||
en: `${baseUrl}/en`,
|
'en': `/en`,
|
||||||
|
'x-default': `/de`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -60,6 +66,9 @@ export const viewport: Viewport = {
|
|||||||
themeColor: '#117c61',
|
themeColor: '#117c61',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return [{ locale: 'de' }, { locale: 'en' }];
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Layout(props: {
|
export default async function Layout(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -148,19 +157,9 @@ export default async function Layout(props: {
|
|||||||
const serverServices = getServerAppServices();
|
const serverServices = getServerAppServices();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { headers } = await import('next/headers');
|
|
||||||
const requestHeaders = await headers();
|
|
||||||
|
|
||||||
// Disable analytics in CI to prevent console noise/score penalties
|
// Disable analytics in CI to prevent console noise/score penalties
|
||||||
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||||
// Skip setting server context for analytics in CI
|
// Skip setting server context for analytics in CI
|
||||||
} else if ('setServerContext' in serverServices.analytics) {
|
|
||||||
(serverServices.analytics as any).setServerContext({
|
|
||||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
|
||||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
|
||||||
referrer: requestHeaders.get('referer') || undefined,
|
|
||||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-side analytics tracking removed to prevent duplicate/empty events.
|
// Server-side analytics tracking removed to prevent duplicate/empty events.
|
||||||
@@ -182,35 +181,38 @@ export default async function Layout(props: {
|
|||||||
lang={safeLocale}
|
lang={safeLocale}
|
||||||
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
|
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
|
||||||
data-scroll-behavior="smooth"
|
data-scroll-behavior="smooth"
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="apple-touch-icon" href="/apple-icon.png" sizes="180x180" />
|
<link rel="apple-touch-icon" href="/apple-icon.png" sizes="180x180" />
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white">
|
<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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button, Heading } from '@/components/ui';
|
import { Button, Heading } from '@/components/ui';
|
||||||
import { Terminal, Search, Home, Mail, Activity, Cpu } from 'lucide-react';
|
import { Terminal, Search, Home, Mail, Activity, Cpu } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
@@ -46,7 +46,7 @@ export default function NotFound() {
|
|||||||
<div className="relative z-10 w-full max-w-4xl mx-auto flex flex-col items-center">
|
<div className="relative z-10 w-full max-w-4xl mx-auto flex flex-col items-center">
|
||||||
{/* 404 Visual */}
|
{/* 404 Visual */}
|
||||||
<div className="relative mb-12">
|
<div className="relative mb-12">
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="relative"
|
className="relative"
|
||||||
@@ -62,7 +62,7 @@ export default function NotFound() {
|
|||||||
|
|
||||||
{/* Search Scan Overlay */}
|
{/* Search Scan Overlay */}
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center">
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center">
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{
|
animate={{
|
||||||
scale: [1, 1.1, 1],
|
scale: [1, 1.1, 1],
|
||||||
opacity: [0.5, 1, 0.5]
|
opacity: [0.5, 1, 0.5]
|
||||||
@@ -71,9 +71,9 @@ export default function NotFound() {
|
|||||||
className="p-5 rounded-full bg-primary/20 border border-primary/40 backdrop-blur-md shadow-[0_0_40px_rgba(var(--color-primary),0.3)]"
|
className="p-5 rounded-full bg-primary/20 border border-primary/40 backdrop-blur-md shadow-[0_0_40px_rgba(var(--color-primary),0.3)]"
|
||||||
>
|
>
|
||||||
<Search className="w-10 h-10 text-primary" />
|
<Search className="w-10 h-10 text-primary" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 w-full items-start">
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 w-full items-start">
|
||||||
|
|||||||
@@ -37,6 +37,41 @@ const mdxComponents = (references: any[]) => ({
|
|||||||
AnimatedCounter,
|
AnimatedCounter,
|
||||||
InteractiveGermanyMap,
|
InteractiveGermanyMap,
|
||||||
ScaleOfImpact,
|
ScaleOfImpact,
|
||||||
|
ResponsiveImage: (props: any) => {
|
||||||
|
// Parse src if it's already a next image proxy
|
||||||
|
let src = props.src;
|
||||||
|
if (typeof src === 'string' && src.startsWith('/_next/image')) {
|
||||||
|
try {
|
||||||
|
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
|
||||||
|
if (urlParam) src = decodeURIComponent(urlParam);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Next.js getImageProps for responsive srcSet generation
|
||||||
|
const {
|
||||||
|
props: { srcSet, src: finalSrc, sizes }
|
||||||
|
} = getImageProps({
|
||||||
|
src,
|
||||||
|
alt: props.alt || '',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
|
||||||
|
quality: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
src={finalSrc}
|
||||||
|
srcSet={srcSet}
|
||||||
|
sizes={sizes}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
img: (props: any) => {
|
img: (props: any) => {
|
||||||
// Parse src if it's already a next image proxy
|
// Parse src if it's already a next image proxy
|
||||||
let src = props.src;
|
let src = props.src;
|
||||||
@@ -87,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') {
|
||||||
@@ -95,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);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
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'}`}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
export default function Template({ children }: { children: React.ReactNode }) {
|
export default function Template({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 20, filter: 'blur(10px)' }}
|
initial={{ opacity: 0, y: 20, filter: 'blur(10px)' }}
|
||||||
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||||
transition={{
|
transition={{
|
||||||
@@ -14,6 +14,6 @@ export default function Template({ children }: { children: React.ReactNode }) {
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
background_color: '#001a4d',
|
background_color: '#001a4d',
|
||||||
theme_color: '#001a4d',
|
theme_color: '#001a4d',
|
||||||
icons: [
|
icons: [
|
||||||
{
|
|
||||||
src: '/favicon.ico',
|
|
||||||
sizes: 'any',
|
|
||||||
type: 'image/x-icon',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
src: '/logo.png',
|
src: '/logo.png',
|
||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
|
|||||||
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">
|
||||||
@@ -98,7 +98,7 @@ export default function ContactForm() {
|
|||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
|
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 "
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
>
|
>
|
||||||
@@ -115,7 +115,7 @@ export default function ContactForm() {
|
|||||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<Heading level={3} 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">
|
||||||
@@ -134,7 +134,7 @@ export default function ContactForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
|
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl ">
|
||||||
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
||||||
{t('form.title')}
|
{t('form.title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
|
<div className={cn('mt-8 ', className)}>
|
||||||
<a
|
<a
|
||||||
href={datasheetPath}
|
href={datasheetPath}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function ExcelDownload({ excelPath, className }: ExcelDownloadPro
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
|
<div className={cn('mt-4 ', className)}>
|
||||||
<a
|
<a
|
||||||
href={excelPath}
|
href={excelPath}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function ProductSidebar({
|
|||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={cn('flex flex-col gap-4 animate-slight-fade-in-from-bottom', className)}>
|
<aside className={cn('flex flex-col gap-4 ', className)}>
|
||||||
{/* Request Quote Form Card */}
|
{/* Request Quote Form Card */}
|
||||||
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
||||||
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center !mt-0 w-full"
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
@@ -126,7 +126,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center !mt-0 w-full"
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 { motion, AnimatePresence } 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";
|
||||||
@@ -289,7 +289,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="fixed inset-0 pointer-events-none z-[9998]">
|
<div className="fixed inset-0 pointer-events-none z-[9998]">
|
||||||
{hoveredRect && (
|
{hoveredRect && (
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -304,7 +304,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedRect && (
|
{selectedRect && (
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
|
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
|
||||||
@@ -324,7 +324,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{selectedElement && (
|
{selectedElement && (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
@@ -475,7 +475,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|||||||
<Check size={20} />
|
<Check size={20} />
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -484,14 +484,14 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showList && (
|
{showList && (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={() => setShowList(false)}
|
onClick={() => setShowList(false)}
|
||||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ x: "100%" }}
|
initial={{ x: "100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "100%" }}
|
exit={{ x: "100%" }}
|
||||||
@@ -618,7 +618,7 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</m.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion, 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';
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export function BenefitGrid({ badge, title, description, benefits }: BenefitGrid
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -116,7 +116,7 @@ export function BenefitGrid({ badge, title, description, benefits }: BenefitGrid
|
|||||||
const animationClass = animationClasses[index % animationClasses.length];
|
const animationClass = animationClasses[index % animationClasses.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key={benefit.id}
|
key={benefit.id}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="group relative overflow-hidden bg-neutral-50/50 border border-neutral-200/60 rounded-3xl p-8 transition-all duration-500 hover:bg-white hover:border-primary/30 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1"
|
className="group relative overflow-hidden bg-neutral-50/50 border border-neutral-200/60 rounded-3xl p-8 transition-all duration-500 hover:bg-white hover:border-primary/30 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1"
|
||||||
@@ -144,10 +144,10 @@ export function BenefitGrid({ badge, title, description, benefits }: BenefitGrid
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } 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';
|
||||||
@@ -122,7 +122,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
@@ -131,11 +131,11 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badgeText}</h2>
|
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badgeText}</h2>
|
||||||
<h3 className="font-heading text-2xl md:text-5xl font-extrabold text-neutral-dark mb-6">{titleText}</h3>
|
<h3 className="font-heading text-2xl md:text-5xl font-extrabold text-neutral-dark mb-6">{titleText}</h3>
|
||||||
<p className="text-lg text-neutral-600">{descriptionText}</p>
|
<p className="text-lg text-neutral-600">{descriptionText}</p>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -144,7 +144,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
>
|
>
|
||||||
{certificates.map((cert, index) => {
|
{certificates.map((cert, index) => {
|
||||||
const isIso = cert.type === 'iso';
|
const isIso = cert.type === 'iso';
|
||||||
const Wrapper = cert.pdfUrl ? motion.a : motion.div;
|
const Wrapper = cert.pdfUrl ? m.a : m.div;
|
||||||
const wrapperProps = cert.pdfUrl ? {
|
const wrapperProps = cert.pdfUrl ? {
|
||||||
href: encodeURI(cert.pdfUrl),
|
href: encodeURI(cert.pdfUrl),
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
@@ -219,7 +219,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</m.div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion, useScroll, useTransform } 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" />
|
||||||
@@ -102,7 +102,7 @@ export function CompanyTimeline({
|
|||||||
|
|
||||||
{/* Animated Central Line */}
|
{/* Animated Central Line */}
|
||||||
<div className="absolute left-[38px] md:left-1/2 top-0 bottom-0 w-1.5 -translate-x-1/2 rounded-full overflow-hidden">
|
<div className="absolute left-[38px] md:left-1/2 top-0 bottom-0 w-1.5 -translate-x-1/2 rounded-full overflow-hidden">
|
||||||
<motion.div
|
<m.div
|
||||||
className="w-full bg-primary drop-shadow-[0_0_8px_rgba(130,237,32,0.8)]"
|
className="w-full bg-primary drop-shadow-[0_0_8px_rgba(130,237,32,0.8)]"
|
||||||
style={{ height: lineHeight }}
|
style={{ height: lineHeight }}
|
||||||
/>
|
/>
|
||||||
@@ -114,7 +114,7 @@ export function CompanyTimeline({
|
|||||||
const Icon = iconMap[milestone.iconName] || Building2;
|
const Icon = iconMap[milestone.iconName] || Building2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key={i}
|
key={i}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
@@ -159,7 +159,7 @@ export function CompanyTimeline({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { motion } 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';
|
||||||
@@ -103,7 +103,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
|
|
||||||
if (isAccent) {
|
if (isAccent) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key={idx}
|
key={idx}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
@@ -125,12 +125,12 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
<AnimatedGlossyBorder color="white" className="opacity-0 group-hover:opacity-100 transition-opacity duration-700" borderWidth={1} />
|
<AnimatedGlossyBorder color="white" className="opacity-0 group-hover:opacity-100 transition-opacity duration-700" borderWidth={1} />
|
||||||
<h4 className="font-heading font-extrabold text-2xl md:text-3xl relative z-10 leading-snug">{item.title}</h4>
|
<h4 className="font-heading font-extrabold text-2xl md:text-3xl relative z-10 leading-snug">{item.title}</h4>
|
||||||
{item.description && <p className="text-white/80 text-base md:text-lg mt-3 relative z-10 font-medium">{item.description}</p>}
|
{item.description && <p className="text-white/80 text-base md:text-lg mt-3 relative z-10 font-medium">{item.description}</p>}
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key={idx}
|
key={idx}
|
||||||
initial={{ opacity: 0, y: 20, scale: 0.98 }}
|
initial={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||||
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
@@ -182,7 +182,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { motion } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
const ContactMap = dynamic(() => import('@/components/ContactMap'), {
|
const ContactMap = dynamic(() => import('@/components/ContactMap'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -44,21 +44,21 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
<Section className="bg-neutral-light relative overflow-hidden">
|
<Section className="bg-neutral-light relative overflow-hidden">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
||||||
<motion.div
|
<m.div
|
||||||
className="lg:col-span-5 flex flex-col justify-center"
|
className="lg:col-span-5 flex flex-col justify-center"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
>
|
>
|
||||||
<motion.h2 variants={itemVariants} className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Direktkontakt</motion.h2>
|
<m.h2 variants={itemVariants} className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Direktkontakt</m.h2>
|
||||||
<motion.h3 variants={itemVariants} className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-6">Wir sind für Sie da.</motion.h3>
|
<m.h3 variants={itemVariants} className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-6">Wir sind für Sie da.</m.h3>
|
||||||
<motion.p variants={itemVariants} className="text-lg text-text-secondary mb-8 leading-relaxed">
|
<m.p variants={itemVariants} className="text-lg text-text-secondary mb-8 leading-relaxed">
|
||||||
Haben Sie Fragen zu unseren Leistungen oder möchten Sie ein konkretes Projekt besprechen? Rufen Sie uns an oder schreiben Sie uns eine E-Mail.
|
Haben Sie Fragen zu unseren Leistungen oder möchten Sie ein konkretes Projekt besprechen? Rufen Sie uns an oder schreiben Sie uns eine E-Mail.
|
||||||
</motion.p>
|
</m.p>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<motion.div variants={itemVariants} className="group flex items-start gap-4">
|
<m.div variants={itemVariants} className="group flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,9 +68,9 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
<a href="tel:+4935616857733">+49 (0) 3561 / 68577 33</a>
|
<a href="tel:+4935616857733">+49 (0) 3561 / 68577 33</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
<motion.div variants={itemVariants} className="group flex items-start gap-4">
|
<m.div variants={itemVariants} className="group flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,9 +80,9 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
<a href="mailto:info@e-tib.com">info@e-tib.com</a>
|
<a href="mailto:info@e-tib.com">info@e-tib.com</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
<motion.div variants={itemVariants} className="group flex items-start gap-4">
|
<m.div variants={itemVariants} className="group flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary shrink-0 transition-all duration-500 group-hover:bg-primary group-hover:text-white group-hover:scale-110 group-hover:-rotate-3">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,12 +94,12 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
Deutschland
|
Deutschland
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<motion.div
|
<m.div
|
||||||
className="lg:col-span-7 relative"
|
className="lg:col-span-7 relative"
|
||||||
variants={formVariants}
|
variants={formVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -113,12 +113,12 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</motion.div>
|
</m.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
{showMap && (
|
{showMap && (
|
||||||
<motion.section
|
<m.section
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
whileInView={{ opacity: 1 }}
|
whileInView={{ opacity: 1 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
@@ -135,7 +135,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</motion.section>
|
</m.section>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
export function DataGridPulse() {
|
export function DataGridPulse() {
|
||||||
@@ -19,12 +19,12 @@ export function DataGridPulse() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pulsing horizontal lines (Data Flow) */}
|
{/* Pulsing horizontal lines (Data Flow) */}
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{ x: ["-100%", "100%"] }}
|
animate={{ x: ["-100%", "100%"] }}
|
||||||
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
|
||||||
className="absolute top-1/3 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary/30 to-transparent blur-[1px]"
|
className="absolute top-1/3 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary/30 to-transparent blur-[1px]"
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{ x: ["100%", "-100%"] }}
|
animate={{ x: ["100%", "-100%"] }}
|
||||||
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
|
||||||
className="absolute top-2/3 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary/25 to-transparent blur-[1px]"
|
className="absolute top-2/3 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary/25 to-transparent blur-[1px]"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { motion, useScroll, useTransform } 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() {
|
||||||
@@ -33,17 +33,17 @@ export function DeepDrillAnimation() {
|
|||||||
|
|
||||||
{/* The Drill Path */}
|
{/* The Drill Path */}
|
||||||
<div className="relative w-2 h-96 bg-white/10 rounded-full mb-16">
|
<div className="relative w-2 h-96 bg-white/10 rounded-full mb-16">
|
||||||
<motion.div
|
<m.div
|
||||||
className="absolute top-0 left-0 w-full bg-primary rounded-full"
|
className="absolute top-0 left-0 w-full bg-primary rounded-full"
|
||||||
style={{ height: drillDepth, boxShadow: '0 0 20px rgba(130,237,32,0.6)' }}
|
style={{ height: drillDepth, boxShadow: '0 0 20px rgba(130,237,32,0.6)' }}
|
||||||
>
|
>
|
||||||
{/* The Drill Head */}
|
{/* The Drill Head */}
|
||||||
<div className="absolute -bottom-2 -left-2 w-6 h-6 bg-white rounded-full border-4 border-primary shadow-[0_0_15px_rgba(255,255,255,1)]" />
|
<div className="absolute -bottom-2 -left-2 w-6 h-6 bg-white rounded-full border-4 border-primary shadow-[0_0_15px_rgba(255,255,255,1)]" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The Reveal Data */}
|
{/* The Reveal Data */}
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
@@ -56,7 +56,7 @@ export function DeepDrillAnimation() {
|
|||||||
<div className="text-sm font-bold uppercase tracking-widest text-primary">
|
<div className="text-sm font-bold uppercase tracking-widest text-primary">
|
||||||
{locale === 'en' ? 'Drilled distance since 2023' : 'Gebohrte Strecke seit 2023'}
|
{locale === 'en' ? 'Drilled distance since 2023' : 'Gebohrte Strecke seit 2023'}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export function GrowthChart() {
|
export function GrowthChart() {
|
||||||
@@ -42,7 +42,7 @@ export function GrowthChart() {
|
|||||||
{/* Kabelverlegung Bar */}
|
{/* Kabelverlegung Bar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1 bg-neutral-100 rounded-full h-6 overflow-hidden relative">
|
<div className="flex-1 bg-neutral-100 rounded-full h-6 overflow-hidden relative">
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
whileInView={{ width: `${kabelWidth}%` }}
|
whileInView={{ width: `${kabelWidth}%` }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
@@ -58,7 +58,7 @@ export function GrowthChart() {
|
|||||||
{/* Offener Tiefbau Bar */}
|
{/* Offener Tiefbau Bar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1 bg-neutral-100 rounded-full h-5 overflow-hidden relative">
|
<div className="flex-1 bg-neutral-100 rounded-full h-5 overflow-hidden relative">
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
whileInView={{ width: `${offenerWidth}%` }}
|
whileInView={{ width: `${offenerWidth}%` }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
@@ -74,7 +74,7 @@ export function GrowthChart() {
|
|||||||
{/* HDD Bar */}
|
{/* HDD Bar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1 bg-neutral-100 rounded-full h-4 overflow-hidden relative">
|
<div className="flex-1 bg-neutral-100 rounded-full h-4 overflow-hidden relative">
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
whileInView={{ width: `${hddWidth}%` }}
|
whileInView={{ width: `${hddWidth}%` }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
|||||||
@@ -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 { motion } from 'framer-motion';
|
|
||||||
import { Badge, Container, Heading } from '@/components/ui';
|
import { Badge, Container, Heading } from '@/components/ui';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
@@ -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 && (
|
||||||
<motion.div
|
<div
|
||||||
key={`hero-bg-${pathname}`}
|
key={`hero-bg-${pathname}`}
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0 animate-hero-zoom-out"
|
||||||
initial={{ scale: 1.05, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={bgSrc}
|
src={bgSrc}
|
||||||
@@ -74,37 +52,34 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
|||||||
|
|
||||||
{/* Top Fade for Header Navigation Readability */}
|
{/* Top Fade for Header Navigation Readability */}
|
||||||
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
|
<Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
|
||||||
<motion.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 && (
|
||||||
<motion.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>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<motion.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>
|
||||||
</motion.div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<motion.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>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ctaLabel && ctaHref && (
|
{ctaLabel && ctaHref && (
|
||||||
<motion.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>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,11 +86,14 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
src={posterSrc}
|
src={posterSrc}
|
||||||
alt={posterAlt}
|
alt={posterAlt}
|
||||||
fill
|
fill
|
||||||
unoptimized
|
className="object-cover z-[1] pointer-events-none"
|
||||||
className="object-cover z-[1] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={25}
|
||||||
priority
|
priority
|
||||||
|
fetchPriority="high"
|
||||||
/>
|
/>
|
||||||
|
{/* Fast compositing layer to replace the heavy CSS filters on the image */}
|
||||||
|
<div className="absolute inset-0 bg-black/20 z-[1] pointer-events-none mix-blend-multiply" />
|
||||||
|
|
||||||
{/* Render video on top if available, but defer src to avoid blocking LCP */}
|
{/* Render video on top if available, but defer src to avoid blocking LCP */}
|
||||||
{videoUrl && (
|
{videoUrl && (
|
||||||
@@ -116,10 +119,10 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
{/* Top Fade for Header Navigation Readability */}
|
{/* Top Fade for Header Navigation Readability */}
|
||||||
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
||||||
|
|
||||||
<div className="container 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="animate-in fade-in slide-in-from-bottom-10 duration-1000 ease-out fill-mode-both"
|
className="relative"
|
||||||
>
|
>
|
||||||
<h1
|
<h1
|
||||||
className="font-heading font-bold text-3xl sm:text-4xl md:text-6xl lg:text-8xl text-white mb-6 uppercase tracking-tight drop-shadow-lg"
|
className="font-heading font-bold text-3xl sm:text-4xl md:text-6xl lg:text-8xl text-white mb-6 uppercase tracking-tight drop-shadow-lg"
|
||||||
|
|||||||
@@ -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 { motion, 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" />
|
|
||||||
)}
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: 'spring', delay: idx * 0.1 }}
|
|
||||||
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
|
||||||
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
|
||||||
} ${
|
|
||||||
isHQ || isBranch
|
|
||||||
? 'w-7 h-7 bg-primary text-[#050B14]'
|
|
||||||
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
MajorNode.displayName = 'MajorNode';
|
|
||||||
|
|
||||||
export function InteractiveGermanyMap({
|
export function InteractiveGermanyMap({
|
||||||
badge,
|
badge,
|
||||||
@@ -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 && (
|
|
||||||
<motion.div
|
|
||||||
key="tooltip"
|
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="absolute z-[9999] pointer-events-auto bottom-[-60px] left-1/2 -translate-x-1/2 w-[calc(100vw_-_2rem)] max-w-[320px] md:w-max md:bottom-auto md:left-[var(--md-left)] md:top-[var(--md-top)] md:[transform:var(--md-transform)]"
|
|
||||||
style={{
|
|
||||||
'--md-left': `${activeLocation.x}%`,
|
|
||||||
'--md-top': `${activeLocation.y}%`,
|
|
||||||
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
|
|
||||||
{activeLocation.featuredImage && (
|
|
||||||
<div className="relative w-full h-32 bg-neutral-900">
|
|
||||||
<Image
|
|
||||||
src={activeLocation.featuredImage}
|
|
||||||
alt={activeLocation.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] to-transparent opacity-90" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<MapPin className="w-3.5 h-3.5 text-primary" />
|
|
||||||
<span className="text-xs font-bold text-primary uppercase tracking-wider">
|
|
||||||
{t(`types.${activeLocation.type === 'minor_node' ? 'project' : activeLocation.type}`)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="font-heading font-bold text-lg leading-tight mb-2">
|
|
||||||
{activeLocation.name}
|
|
||||||
</div>
|
|
||||||
{activeLocation.type !== 'minor_node' ? (
|
|
||||||
<div className="text-white/60 text-sm line-clamp-3 mb-3">
|
|
||||||
{activeLocation.description}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-white/60 text-xs mb-3">
|
|
||||||
{t(`descriptions.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(activeLocation.details || activeLocation.type === 'minor_node') && (
|
|
||||||
<ul className="mb-3 space-y-1.5 border-t border-white/10 pt-3">
|
|
||||||
{activeLocation.type === 'minor_node' ? (
|
|
||||||
<>
|
|
||||||
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
||||||
<span>{t(`features.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}</span>
|
|
||||||
</li>
|
|
||||||
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
||||||
<span>{t('features.method')}</span>
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
activeLocation.details?.map((detail, i) => (
|
|
||||||
<li key={i} className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
|
||||||
<span>{detail}</span>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeLocation.href && (activeLocation.type === 'hq' || activeLocation.type === 'branch') && (
|
|
||||||
<Link
|
|
||||||
href={activeLocation.href.startsWith('/') ? `/${locale}${activeLocation.href}` : activeLocation.href}
|
|
||||||
className="inline-flex items-center text-xs font-bold text-primary hover:text-white transition-colors mt-2"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{t('learnMore')}
|
|
||||||
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 { motion } 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,10 +69,10 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-12 md:py-24">
|
<div className="py-12 md:py-24">
|
||||||
<div className="container 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">
|
||||||
<motion.h3
|
<m.h3
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
@@ -81,8 +81,8 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
>
|
>
|
||||||
<span className="w-12 h-1.5 bg-primary rounded-full" />
|
<span className="w-12 h-1.5 bg-primary rounded-full" />
|
||||||
{fairsTitle}
|
{fairsTitle}
|
||||||
</motion.h3>
|
</m.h3>
|
||||||
<motion.div
|
<m.div
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
@@ -159,25 +159,25 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
|
|
||||||
if (isLink) {
|
if (isLink) {
|
||||||
return (
|
return (
|
||||||
<motion.a variants={itemVariants} key={`fair-${idx}`} href={messe.url} target="_blank" rel="noopener noreferrer" className={`${baseClasses} cursor-pointer`}>
|
<m.a variants={itemVariants} key={`fair-${idx}`} href={messe.url} target="_blank" rel="noopener noreferrer" className={`${baseClasses} cursor-pointer`}>
|
||||||
{innerContent}
|
{innerContent}
|
||||||
</motion.a>
|
</m.a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div variants={itemVariants} key={`fair-${idx}`} className={baseClasses}>
|
<m.div variants={itemVariants} key={`fair-${idx}`} className={baseClasses}>
|
||||||
{innerContent}
|
{innerContent}
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showJobs && (
|
{showJobs && (
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<motion.h2
|
<m.h2
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
@@ -185,10 +185,10 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
className="font-heading font-extrabold text-3xl md:text-4xl text-neutral-dark mb-6 md:mb-8"
|
className="font-heading font-extrabold text-3xl md:text-4xl text-neutral-dark mb-6 md:mb-8"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</motion.h2>
|
</m.h2>
|
||||||
|
|
||||||
{jobs.length > 0 ? (
|
{jobs.length > 0 ? (
|
||||||
<motion.div
|
<m.div
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
@@ -196,7 +196,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
className="grid gap-6"
|
className="grid gap-6"
|
||||||
>
|
>
|
||||||
{jobs.map((job: any) => (
|
{jobs.map((job: any) => (
|
||||||
<motion.div variants={itemVariants} key={job.id}>
|
<m.div variants={itemVariants} key={job.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/karriere/${job.slug}`}
|
href={`/karriere/${job.slug}`}
|
||||||
className="group bg-white border border-neutral-100 p-8 rounded-2xl shadow-sm hover:shadow-xl hover:border-primary/20 transition-all flex flex-col md:flex-row md:items-center justify-between gap-6 block"
|
className="group bg-white border border-neutral-100 p-8 rounded-2xl shadow-sm hover:shadow-xl hover:border-primary/20 transition-all flex flex-col md:flex-row md:items-center justify-between gap-6 block"
|
||||||
@@ -214,11 +214,11 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</m.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</m.div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
@@ -229,7 +229,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
<Button href={emptyStateLinkHref} variant="primary" className="mt-6">
|
<Button href={emptyStateLinkHref} variant="primary" className="mt-6">
|
||||||
{emptyStateLinkText}
|
{emptyStateLinkText}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</m.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion } 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';
|
||||||
@@ -46,7 +46,8 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
const scrollLeftRef = React.useRef(0);
|
const scrollLeftRef = React.useRef(0);
|
||||||
const isDraggingRef = React.useRef(false);
|
const isDraggingRef = React.useRef(false);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
|
||||||
|
|
||||||
const badge = props.badge || data?.badge || t('badge');
|
const badge = props.badge || data?.badge || t('badge');
|
||||||
const title = props.title || data?.title || t('title');
|
const title = props.title || data?.title || t('title');
|
||||||
const description = props.description || data?.description || t('description');
|
const description = props.description || data?.description || t('description');
|
||||||
@@ -97,87 +98,88 @@ 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 md:flex-row justify-between items-start md:items-end 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 className="text-left">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left max-w-3xl mx-auto md:mx-0 mb-8 md:mb-16">
|
<div className="max-w-3xl mx-auto mb-4 md:mb-8 text-neutral-300">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 md:hidden pointer-events-none" />
|
{/* Fade edges for peek effect */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-16 bg-gradient-to-r from-neutral-dark to-transparent z-20 pointer-events-none" />
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 pointer-events-none" />
|
||||||
|
|
||||||
{/* Pure CSS Full-Bleed Alignment Container */}
|
{/* Carousel: centered first card with peek on both sides */}
|
||||||
<div style={{ '--offset': 'calc(50vw - 50%)' } as React.CSSProperties}>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
onMouseMove={onMouseMove}
|
onMouseMove={onMouseMove}
|
||||||
style={{
|
className={`select-none flex gap-6 overflow-x-auto pb-8 pt-4 px-[calc(50vw-160px)] md:px-[calc(50vw-240px)] [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
|
||||||
marginLeft: 'calc(-1 * var(--offset))',
|
|
||||||
marginRight: 'calc(-1 * var(--offset))',
|
|
||||||
paddingLeft: 'var(--offset)',
|
|
||||||
paddingRight: 'var(--offset)',
|
|
||||||
scrollPaddingLeft: 'var(--offset)',
|
|
||||||
}}
|
|
||||||
className={`select-none flex overflow-x-auto pb-8 pt-4 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
|
|
||||||
>
|
>
|
||||||
{references.map((ref, i) => {
|
{references.map((ref, i) => {
|
||||||
const imgSrc = ref.image
|
const imgSrc = ref.image
|
||||||
? (typeof ref.image === 'string' ? ref.image : ref.image.url)
|
? (typeof ref.image === 'string' ? ref.image : ref.image.url)
|
||||||
: fallbacks[i % fallbacks.length];
|
: fallbacks[i % fallbacks.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key={ref.id}
|
key={ref.id}
|
||||||
initial={{ opacity: 0, x: 20 }}
|
data-card
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
className="flex-shrink-0 w-[320px] md:w-[480px] snap-start group pointer-events-auto mr-6"
|
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||||
>
|
className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto"
|
||||||
<Link
|
|
||||||
href={`/${locale}/referenzen#${ref.slug}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (dragDistanceRef.current > 5) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-testid="reference-tile"
|
|
||||||
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<Image
|
<Link
|
||||||
|
href={`/${locale}/referenzen#${ref.slug}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (dragDistanceRef.current > 5) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={imgSrc}
|
data-testid="reference-tile"
|
||||||
alt={ref.title}
|
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl cursor-pointer"
|
||||||
fill
|
>
|
||||||
sizes="(max-width: 768px) 320px, 480px"
|
<Image
|
||||||
className="object-cover saturate-50 opacity-70 group-hover:scale-105 group-hover:opacity-100 group-hover:saturate-100 transition-all duration-700 ease-in-out pointer-events-none"
|
draggable={false}
|
||||||
/>
|
src={imgSrc}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent opacity-80 group-hover:opacity-90 transition-opacity duration-300 pointer-events-none" />
|
alt={ref.title}
|
||||||
|
fill
|
||||||
<HoverShineOverlay />
|
quality={50}
|
||||||
|
sizes="(max-width: 768px) 320px, 480px"
|
||||||
<div className="absolute bottom-6 left-6 right-6 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-500 pointer-events-none bg-black/60 backdrop-blur-sm p-4 rounded-xl border border-white/10">
|
className="object-cover saturate-50 opacity-70 group-hover:scale-105 group-hover:opacity-100 group-hover:saturate-100 transition-all duration-700 ease-in-out pointer-events-none"
|
||||||
<span className="inline-block py-1 px-3 rounded-full bg-primary/20 text-white text-[10px] font-bold uppercase tracking-widest mb-2 border border-primary/30">
|
/>
|
||||||
{ref.category}
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent opacity-80 group-hover:opacity-90 transition-opacity duration-300 pointer-events-none" />
|
||||||
</span>
|
|
||||||
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
<HoverShineOverlay />
|
||||||
</div>
|
|
||||||
</Link>
|
<div className="absolute bottom-6 left-6 right-6 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-500 pointer-events-none bg-black/60 backdrop-blur-sm p-4 rounded-xl border border-white/10">
|
||||||
</motion.div>
|
<span className="inline-block py-1 px-3 rounded-full bg-primary/20 text-white text-[10px] font-bold uppercase tracking-widest mb-2 border border-primary/30">
|
||||||
);
|
{ref.category}
|
||||||
})}
|
</span>
|
||||||
</div>
|
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container relative z-10 mt-4 flex justify-center">
|
<div className="container mx-auto max-w-7xl relative z-10 mt-4 flex justify-center">
|
||||||
<Link
|
<Link
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
className="px-8 py-4 rounded-xl bg-white/5 border border-white/20 hover:bg-white hover:text-neutral-dark hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] transition-all font-bold flex items-center gap-3 backdrop-blur-sm"
|
className="px-8 py-4 rounded-xl bg-white/5 border border-white/20 hover:bg-white hover:text-neutral-dark hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] transition-all font-bold flex items-center gap-3 backdrop-blur-sm"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { motion, useScroll, useTransform } 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() {
|
||||||
@@ -26,14 +26,14 @@ export function ScaleOfImpact() {
|
|||||||
className="relative w-full min-h-[40vh] md:min-h-[70vh] bg-[#050B14] py-12 md:py-0 flex flex-col items-center justify-center overflow-hidden border-y border-neutral-800"
|
className="relative w-full min-h-[40vh] md:min-h-[70vh] bg-[#050B14] py-12 md:py-0 flex flex-col items-center justify-center overflow-hidden border-y border-neutral-800"
|
||||||
>
|
>
|
||||||
{/* Massive Watermark Numbers */}
|
{/* Massive Watermark Numbers */}
|
||||||
<motion.div
|
<m.div
|
||||||
style={{ opacity: bgOpacity }}
|
style={{ opacity: bgOpacity }}
|
||||||
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none overflow-hidden mix-blend-overlay"
|
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none overflow-hidden mix-blend-overlay"
|
||||||
>
|
>
|
||||||
<div className="font-heading font-black text-[20vw] leading-none text-white opacity-20 whitespace-nowrap">
|
<div className="font-heading font-black text-[20vw] leading-none text-white opacity-20 whitespace-nowrap">
|
||||||
<span className="font-mono tracking-tighter">1.435.223</span>
|
<span className="font-mono tracking-tighter">1.435.223</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
{/* Foreground Content */}
|
{/* Foreground Content */}
|
||||||
<div className="relative z-10 container max-w-7xl mx-auto px-4 flex flex-col items-center text-center">
|
<div className="relative z-10 container max-w-7xl mx-auto px-4 flex flex-col items-center text-center">
|
||||||
@@ -70,7 +70,7 @@ export function ScaleOfImpact() {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
{/* Glowing animated line */}
|
{/* Glowing animated line */}
|
||||||
<motion.path
|
<m.path
|
||||||
d="M0,50 Q250,0 500,50 T1000,50"
|
d="M0,50 Q250,0 500,50 T1000,50"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#ffc107" // warning/yellow color
|
stroke="#ffc107" // warning/yellow color
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion, 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 {
|
||||||
@@ -133,7 +133,7 @@ export function ServiceDetailGrid({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(problemStatement || solutionStatement) && (
|
{(problemStatement || solutionStatement) && (
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
@@ -165,10 +165,10 @@ export function ServiceDetailGrid({
|
|||||||
<p className="text-text-secondary leading-relaxed">{solutionStatement.text}</p>
|
<p className="text-text-secondary leading-relaxed">{solutionStatement.text}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</m.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
className="grid grid-cols-1 md:grid-cols-2 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 gap-8"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -180,7 +180,7 @@ export function ServiceDetailGrid({
|
|||||||
const number = String(idx + 1).padStart(2, '0');
|
const number = String(idx + 1).padStart(2, '0');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key={panel.id}
|
key={panel.id}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className={`group relative overflow-hidden bg-white border border-neutral-200 rounded-3xl p-6 md:p-12 transition-all duration-500 hover:border-primary/40 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1 select-none ${isFullWidth ? 'md:col-span-2' : ''}`}
|
className={`group relative overflow-hidden bg-white border border-neutral-200 rounded-3xl p-6 md:p-12 transition-all duration-500 hover:border-primary/40 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1 select-none ${isFullWidth ? 'md:col-span-2' : ''}`}
|
||||||
@@ -224,10 +224,10 @@ export function ServiceDetailGrid({
|
|||||||
</ul>
|
</ul>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion, 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">
|
||||||
@@ -80,7 +80,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
|
|
||||||
<div className="relative -mx-4 md:mx-0">
|
<div className="relative -mx-4 md:mx-0">
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 md:hidden pointer-events-none" />
|
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 md:hidden pointer-events-none" />
|
||||||
<motion.div
|
<m.div
|
||||||
className="flex overflow-x-auto md:overflow-visible snap-x snap-mandatory pb-6 md:pb-0 md:grid md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 px-4 md:px-0 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
className="flex overflow-x-auto md:overflow-visible snap-x snap-mandatory pb-6 md:pb-0 md:grid md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 px-4 md:px-0 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -128,7 +128,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
? 'opacity-30 grayscale-0 scale-105'
|
? 'opacity-30 grayscale-0 scale-105'
|
||||||
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110'
|
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110'
|
||||||
}`}
|
}`}
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
sizes="(max-width: 768px) 200px, 384px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key={index}
|
key={index}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="h-full flex-shrink-0 snap-center w-[85%] sm:w-[70%] md:w-full"
|
className="h-full flex-shrink-0 snap-center w-[85%] sm:w-[70%] md:w-full"
|
||||||
@@ -255,10 +255,10 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
{CardContent}
|
{CardContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 { motion } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
export interface SupportCTAProps {
|
export interface SupportCTAProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -39,7 +39,7 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
|
|||||||
const { title, description, buttonLabel, buttonHref } = props;
|
const { title, description, buttonLabel, buttonHref } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
@@ -48,9 +48,9 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform duration-700 group-hover:translate-x-1/3" />
|
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform duration-700 group-hover:translate-x-1/3" />
|
||||||
<div className="relative z-10 max-w-2xl">
|
<div className="relative z-10 max-w-2xl">
|
||||||
<motion.h3 variants={itemVariants} className="text-2xl md:text-3xl font-bold mb-4">{title}</motion.h3>
|
<m.h3 variants={itemVariants} className="text-2xl md:text-3xl font-bold mb-4">{title}</m.h3>
|
||||||
<motion.p variants={itemVariants} className="text-lg text-white/70 mb-8">{description}</motion.p>
|
<m.p variants={itemVariants} className="text-lg text-white/70 mb-8">{description}</m.p>
|
||||||
<motion.div variants={itemVariants}>
|
<m.div variants={itemVariants}>
|
||||||
<TrackedLink
|
<TrackedLink
|
||||||
href={buttonHref}
|
href={buttonHref}
|
||||||
className={getButtonClasses('accent', 'lg')}
|
className={getButtonClasses('accent', 'lg')}
|
||||||
@@ -66,8 +66,8 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
<ButtonOverlay variant="accent" />
|
<ButtonOverlay variant="accent" />
|
||||||
</TrackedLink>
|
</TrackedLink>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion } 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>
|
||||||
@@ -40,7 +40,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 xl:gap-10">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 xl:gap-10">
|
||||||
{members.map((member, i) => (
|
{members.map((member, i) => (
|
||||||
<motion.div
|
<m.div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
@@ -104,7 +104,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { motion, useScroll, useTransform, useSpring } 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,45 +25,56 @@ 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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{ x, y }}
|
style={{ x, y }}
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +91,7 @@ export function CorporateBackground() {
|
|||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-[50] overflow-hidden pointer-events-none hidden md:block" aria-hidden="true">
|
<div className="absolute inset-0 z-[50] overflow-hidden pointer-events-none hidden md:block" aria-hidden="true">
|
||||||
{/* 1. Top Right - Medium, rotating moderately */}
|
{/* 1. Top Right - Medium, rotating moderately */}
|
||||||
<motion.div
|
<m.div
|
||||||
style={{
|
style={{
|
||||||
y: y1,
|
y: y1,
|
||||||
animationDelay: '0s',
|
animationDelay: '0s',
|
||||||
@@ -90,18 +101,18 @@ export function CorporateBackground() {
|
|||||||
className="absolute top-[0%] right-0 translate-x-[40%] w-[400px] h-[400px] md:w-[700px] md:h-[700px] animate-bg-pulse-20"
|
className="absolute top-[0%] right-0 translate-x-[40%] w-[400px] h-[400px] md:w-[700px] md:h-[700px] animate-bg-pulse-20"
|
||||||
>
|
>
|
||||||
<MagneticRing pullRadius={700} pullStrength={40} className="w-full h-full">
|
<MagneticRing pullRadius={700} pullStrength={40} className="w-full h-full">
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 40, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 40, repeat: Infinity, ease: "linear" }}
|
||||||
className="w-full h-full text-primary"
|
className="w-full h-full text-primary"
|
||||||
>
|
>
|
||||||
<LogoArcs className="w-full h-full" />
|
<LogoArcs className="w-full h-full" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</MagneticRing>
|
</MagneticRing>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
{/* 2. Quarter way down left - Smaller, rotating fast reverse */}
|
{/* 2. Quarter way down left - Smaller, rotating fast reverse */}
|
||||||
<motion.div
|
<m.div
|
||||||
style={{
|
style={{
|
||||||
y: y2,
|
y: y2,
|
||||||
animationDelay: '3s',
|
animationDelay: '3s',
|
||||||
@@ -111,18 +122,18 @@ export function CorporateBackground() {
|
|||||||
className="absolute top-[20%] left-0 -translate-x-[40%] w-[300px] h-[300px] md:w-[500px] md:h-[500px] animate-bg-pulse-15"
|
className="absolute top-[20%] left-0 -translate-x-[40%] w-[300px] h-[300px] md:w-[500px] md:h-[500px] animate-bg-pulse-15"
|
||||||
>
|
>
|
||||||
<MagneticRing pullRadius={500} pullStrength={50} className="w-full h-full">
|
<MagneticRing pullRadius={500} pullStrength={50} className="w-full h-full">
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{ rotate: -360 }}
|
animate={{ rotate: -360 }}
|
||||||
transition={{ duration: 30, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 30, repeat: Infinity, ease: "linear" }}
|
||||||
className="w-full h-full text-secondary"
|
className="w-full h-full text-secondary"
|
||||||
>
|
>
|
||||||
<LogoArcs className="w-full h-full -rotate-45" />
|
<LogoArcs className="w-full h-full -rotate-45" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</MagneticRing>
|
</MagneticRing>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
{/* 3. Halfway down right - Medium, rotating slow */}
|
{/* 3. Halfway down right - Medium, rotating slow */}
|
||||||
<motion.div
|
<m.div
|
||||||
style={{
|
style={{
|
||||||
y: y3,
|
y: y3,
|
||||||
animationDelay: '6s',
|
animationDelay: '6s',
|
||||||
@@ -132,18 +143,18 @@ export function CorporateBackground() {
|
|||||||
className="absolute top-[45%] right-0 translate-x-[30%] w-[350px] h-[350px] md:w-[600px] md:h-[600px] animate-bg-pulse-15"
|
className="absolute top-[45%] right-0 translate-x-[30%] w-[350px] h-[350px] md:w-[600px] md:h-[600px] animate-bg-pulse-15"
|
||||||
>
|
>
|
||||||
<MagneticRing pullRadius={600} pullStrength={35} className="w-full h-full">
|
<MagneticRing pullRadius={600} pullStrength={35} className="w-full h-full">
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 60, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 60, repeat: Infinity, ease: "linear" }}
|
||||||
className="w-full h-full text-primary"
|
className="w-full h-full text-primary"
|
||||||
>
|
>
|
||||||
<LogoArcs className="w-full h-full rotate-90" />
|
<LogoArcs className="w-full h-full rotate-90" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</MagneticRing>
|
</MagneticRing>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
{/* 4. Bottom left - Larger, rotating moderately */}
|
{/* 4. Bottom left - Larger, rotating moderately */}
|
||||||
<motion.div
|
<m.div
|
||||||
style={{
|
style={{
|
||||||
y: y4,
|
y: y4,
|
||||||
animationDelay: '9s',
|
animationDelay: '9s',
|
||||||
@@ -153,18 +164,18 @@ export function CorporateBackground() {
|
|||||||
className="absolute top-[70%] left-0 -translate-x-[40%] w-[500px] h-[500px] md:w-[800px] md:h-[800px] animate-bg-pulse-20"
|
className="absolute top-[70%] left-0 -translate-x-[40%] w-[500px] h-[500px] md:w-[800px] md:h-[800px] animate-bg-pulse-20"
|
||||||
>
|
>
|
||||||
<MagneticRing pullRadius={800} pullStrength={30} className="w-full h-full">
|
<MagneticRing pullRadius={800} pullStrength={30} className="w-full h-full">
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{ rotate: -360 }}
|
animate={{ rotate: -360 }}
|
||||||
transition={{ duration: 50, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 50, repeat: Infinity, ease: "linear" }}
|
||||||
className="w-full h-full text-secondary"
|
className="w-full h-full text-secondary"
|
||||||
>
|
>
|
||||||
<LogoArcs className="w-full h-full rotate-180" />
|
<LogoArcs className="w-full h-full rotate-180" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</MagneticRing>
|
</MagneticRing>
|
||||||
</motion.div>
|
</m.div>
|
||||||
|
|
||||||
{/* 5. Deep bottom right - Smallest, very fast */}
|
{/* 5. Deep bottom right - Smallest, very fast */}
|
||||||
<motion.div
|
<m.div
|
||||||
style={{
|
style={{
|
||||||
y: y5,
|
y: y5,
|
||||||
animationDelay: '12s',
|
animationDelay: '12s',
|
||||||
@@ -174,15 +185,15 @@ export function CorporateBackground() {
|
|||||||
className="absolute top-[88%] right-0 translate-x-[50%] w-[250px] h-[250px] md:w-[400px] md:h-[400px] animate-bg-pulse-15"
|
className="absolute top-[88%] right-0 translate-x-[50%] w-[250px] h-[250px] md:w-[400px] md:h-[400px] animate-bg-pulse-15"
|
||||||
>
|
>
|
||||||
<MagneticRing pullRadius={400} pullStrength={60} className="w-full h-full">
|
<MagneticRing pullRadius={400} pullStrength={60} className="w-full h-full">
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
||||||
className="w-full h-full text-primary"
|
className="w-full h-full text-primary"
|
||||||
>
|
>
|
||||||
<LogoArcs className="w-full h-full -rotate-12" />
|
<LogoArcs className="w-full h-full -rotate-12" />
|
||||||
</motion.div>
|
</m.div>
|
||||||
</MagneticRing>
|
</MagneticRing>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
7
components/decorations/DynamicCorporateBackground.tsx
Normal file
7
components/decorations/DynamicCorporateBackground.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
export const DynamicCorporateBackground = dynamic(
|
||||||
|
() => import('./CorporateBackground').then(mod => mod.CorporateBackground),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
export function EUFundingBadge() {
|
export function EUFundingBadge() {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
@@ -36,6 +36,6 @@ export function EUFundingBadge() {
|
|||||||
Europäischen Union
|
Europäischen Union
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion } 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';
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === 'success' ? (
|
{status === 'success' ? (
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="bg-primary/10 border border-primary text-primary p-6 rounded-xl flex items-start gap-4"
|
className="bg-primary/10 border border-primary text-primary p-6 rounded-xl flex items-start gap-4"
|
||||||
@@ -49,9 +49,9 @@ export function ContactForm() {
|
|||||||
<h4 className="font-bold text-lg mb-1">Anfrage erfolgreich gesendet</h4>
|
<h4 className="font-bold text-lg mb-1">Anfrage erfolgreich gesendet</h4>
|
||||||
<p>Vielen Dank für Ihr Interesse an der E-TIB Gruppe. Ein Ansprechpartner wird sich zeitnah mit Ihnen in Verbindung setzen.</p>
|
<p>Vielen Dank für Ihr Interesse an der E-TIB Gruppe. Ein Ansprechpartner wird sich zeitnah mit Ihnen in Verbindung setzen.</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
) : status === 'error' ? (
|
) : status === 'error' ? (
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="bg-red-500/10 border border-red-500 text-red-600 p-6 rounded-xl flex items-start gap-4 mb-6"
|
className="bg-red-500/10 border border-red-500 text-red-600 p-6 rounded-xl flex items-start gap-4 mb-6"
|
||||||
@@ -64,7 +64,7 @@ export function ContactForm() {
|
|||||||
<p>Leider ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie uns direkt per E-Mail oder Telefon.</p>
|
<p>Leider ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie uns direkt per E-Mail oder Telefon.</p>
|
||||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => setStatus('idle')}>Erneut versuchen</Button>
|
<Button variant="outline" size="sm" className="mt-4" onClick={() => setStatus('idle')}>Erneut versuchen</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{status !== 'success' && status !== 'error' && (
|
{status !== 'success' && status !== 'error' && (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function Experience({ data }: { data?: any }) {
|
|||||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||||
alt={data?.subtitle || t('subtitle')}
|
alt={data?.subtitle || t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
className="object-cover object-center scale-105 "
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
quality={100}
|
quality={100}
|
||||||
/>
|
/>
|
||||||
@@ -34,7 +34,7 @@ export default function Experience({ data }: { data?: any }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
<div className="animate-fade-in">
|
<div className="">
|
||||||
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||||
{data?.badge1 || t('certifiedQuality')}
|
{data?.badge1 || t('certifiedQuality')}
|
||||||
</dt>
|
</dt>
|
||||||
@@ -42,7 +42,7 @@ export default function Experience({ data }: { data?: any }) {
|
|||||||
{data?.badge1Text || t('vdeApproved')}
|
{data?.badge1Text || t('vdeApproved')}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
<div className="" style={{ animationDelay: '100ms' }}>
|
||||||
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||||
{data?.badge2 || t('fullSpectrum')}
|
{data?.badge2 || t('fullSpectrum')}
|
||||||
</dt>
|
</dt>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
/<\/green>/g,
|
/<\/green>/g,
|
||||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 " style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -117,7 +117,7 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
{chunks}
|
{chunks}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 "
|
||||||
style={{ animationDelay: '500ms' }}
|
style={{ animationDelay: '500ms' }}
|
||||||
>
|
>
|
||||||
<Scribble variant="circle" />
|
<Scribble variant="circle" />
|
||||||
@@ -199,12 +199,12 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<div className="relative md:absolute inset-0 z-0 w-full h-[35vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
<div className="relative md:absolute inset-0 z-0 w-full h-[35vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none ">
|
||||||
<HeroIllustration />
|
<HeroIllustration />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block "
|
||||||
style={{ animationDelay: '2000ms' }}
|
style={{ animationDelay: '2000ms' }}
|
||||||
>
|
>
|
||||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
|||||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||||
alt={data?.subtitle || t('subtitle')}
|
alt={data?.subtitle || t('subtitle')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom"
|
className="object-cover scale-105 "
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
quality={100}
|
quality={100}
|
||||||
/>
|
/>
|
||||||
@@ -24,7 +24,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-3xl text-white animate-slide-up">
|
<div className="max-w-3xl text-white ">
|
||||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8">
|
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8">
|
||||||
<span className="text-white">{data?.title || t('title')}</span>
|
<span className="text-white">{data?.title || t('title')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function VideoSection({ data }: { data?: any }) {
|
|||||||
</video>
|
</video>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center pointer-events-none">
|
||||||
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
<div className="max-w-5xl px-6 text-center pointer-events-auto">
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span
|
<span
|
||||||
|
|||||||
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>
|
||||||
|
|||||||
@@ -77,10 +77,11 @@ describe('Header Dropdown Navigation TDD', () => {
|
|||||||
rerender(<Header navLinks={mockNavLinks} />);
|
rerender(<Header navLinks={mockNavLinks} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Assert dropdown is closed (children are not in the document)
|
// 6. Assert dropdown is closed (visually hidden via CSS)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByText('Kabelbau')).toBeNull();
|
const dropdown = screen.getByText('Kabelbau').closest('div[style*="perspective"]');
|
||||||
expect(screen.queryByText('Bohrtechnik')).toBeNull();
|
expect(dropdown?.className).toContain('invisible');
|
||||||
|
expect(dropdown?.className).toContain('opacity-0');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import * as React from 'react';
|
|||||||
import { TransitionLink } from '@/components/ui/TransitionLink';
|
import { TransitionLink } from '@/components/ui/TransitionLink';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { motion, useScroll, useTransform, AnimatePresence } from 'framer-motion';
|
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||||
|
|
||||||
export interface NavLink {
|
export interface NavLink {
|
||||||
@@ -25,21 +24,30 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
||||||
const pathname = usePathname() || '/';
|
const pathname = usePathname() || '/';
|
||||||
|
|
||||||
// Scroll bound shine effect (continuous looping as user scrolls)
|
const shineRef = React.useRef<HTMLDivElement>(null);
|
||||||
const { scrollY } = useScroll();
|
|
||||||
const shineX = useTransform(scrollY, (y) => {
|
|
||||||
const adjustedY = Math.max(0, y - 20); // Start after the solid mode kicks in
|
|
||||||
const range = 800; // 1 full sweep per 800px scrolled
|
|
||||||
const progress = (adjustedY % range) / range;
|
|
||||||
return `${-150 + progress * 300}%`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine current locale from pathname
|
// Determine current locale from pathname
|
||||||
const currentLocale = pathname.startsWith('/en') ? 'en' : 'de';
|
const currentLocale = pathname.startsWith('/en') ? 'en' : 'de';
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
let animationFrameId: number;
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
setIsScrolled(window.scrollY > 20);
|
setIsScrolled(window.scrollY > 20);
|
||||||
|
|
||||||
|
if (shineRef.current) {
|
||||||
|
const y = window.scrollY;
|
||||||
|
const adjustedY = Math.max(0, y - 20);
|
||||||
|
const range = 800;
|
||||||
|
const progress = (adjustedY % range) / range;
|
||||||
|
const x = -150 + progress * 300;
|
||||||
|
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
animationFrameId = requestAnimationFrame(() => {
|
||||||
|
if (shineRef.current) {
|
||||||
|
shineRef.current.style.transform = `translateX(${x}%)`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('scroll', handleScroll);
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
@@ -88,8 +96,8 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
>
|
>
|
||||||
{/* Scroll bound shine effect */}
|
{/* Scroll bound shine effect */}
|
||||||
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden rounded-full">
|
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden rounded-full">
|
||||||
<motion.div
|
<div
|
||||||
style={{ x: shineX }}
|
ref={shineRef}
|
||||||
className={`absolute top-0 h-[200%] w-[150%] bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-20deg] mix-blend-overlay transition-opacity duration-300 ${isSolidMode ? 'opacity-100' : 'opacity-0'}`}
|
className={`absolute top-0 h-[200%] w-[150%] bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-20deg] mix-blend-overlay transition-opacity duration-300 ${isSolidMode ? 'opacity-100' : 'opacity-0'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,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
|
||||||
@@ -256,12 +265,10 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Framer Motion Active Indicator */}
|
{/* Active Indicator */}
|
||||||
{isActive && !link.children && (
|
{isActive && !link.children && (
|
||||||
<motion.div
|
<div
|
||||||
layoutId="header-active-indicator"
|
|
||||||
className={`absolute -bottom-1 left-0 right-0 h-0.5 rounded-full ${isSolidMode ? 'bg-primary' : 'bg-white'}`}
|
className={`absolute -bottom-1 left-0 right-0 h-0.5 rounded-full ${isSolidMode ? 'bg-primary' : 'bg-white'}`}
|
||||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -269,15 +276,13 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
|
|||||||
<span className={`absolute -bottom-1 left-1/2 w-0 h-0.5 -translate-x-1/2 rounded-full transition-all duration-300 ease-out group-hover:w-full ${isSolidMode ? 'bg-primary/30' : 'bg-white/30'} ${isActive ? 'opacity-0' : 'opacity-100'}`} />
|
<span className={`absolute -bottom-1 left-1/2 w-0 h-0.5 -translate-x-1/2 rounded-full transition-all duration-300 ease-out group-hover:w-full ${isSolidMode ? 'bg-primary/30' : 'bg-white/30'} ${isActive ? 'opacity-0' : 'opacity-100'}`} />
|
||||||
</TransitionLink>
|
</TransitionLink>
|
||||||
|
|
||||||
<AnimatePresence>
|
<>
|
||||||
{isHovered && link.children && (
|
{link.children && (
|
||||||
<motion.div
|
<div
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.95, rotateX: -10 }}
|
className={`absolute top-full left-1/2 -translate-x-1/2 pt-6 w-64 z-50 transition-all duration-300 origin-top ${
|
||||||
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
|
isHovered ? 'opacity-100 scale-100 translate-y-0 visible' : 'opacity-0 scale-95 translate-y-2 invisible'
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95, rotateX: -10 }}
|
}`}
|
||||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
style={{ perspective: 1000 }}
|
||||||
style={{ perspective: 1000, transformOrigin: 'top center' }}
|
|
||||||
className="absolute top-full left-1/2 -translate-x-1/2 pt-6 w-64 z-50"
|
|
||||||
>
|
>
|
||||||
<div className={`p-2.5 rounded-3xl shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] border ${isSolidMode ? 'bg-white/95 border-neutral-200/60' : 'bg-[#050B14]/95 border-white/[0.08] shadow-[0_0_40px_rgba(0,0,0,0.4)]'} backdrop-blur-2xl flex flex-col gap-1 relative overflow-hidden`}>
|
<div className={`p-2.5 rounded-3xl shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] border ${isSolidMode ? 'bg-white/95 border-neutral-200/60' : 'bg-[#050B14]/95 border-white/[0.08] shadow-[0_0_40px_rgba(0,0,0,0.4)]'} backdrop-blur-2xl flex flex-col gap-1 relative overflow-hidden`}>
|
||||||
{/* Top glowing accent line */}
|
{/* Top glowing accent line */}
|
||||||
@@ -321,9 +326,9 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</>
|
||||||
</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 { motion, AnimatePresence } from 'framer-motion';
|
import { m, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
export interface NavLink {
|
export interface NavLink {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -95,7 +95,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-[9999] px-2 pb-[calc(env(safe-area-inset-bottom)_+_12px)] pt-4 pointer-events-none flex justify-center">
|
<div className="md:hidden fixed bottom-0 left-0 right-0 z-[9999] px-2 pb-[calc(env(safe-area-inset-bottom)_+_12px)] pt-4 pointer-events-none flex justify-center">
|
||||||
<nav className="flex justify-between items-center bg-white/75 backdrop-blur-2xl border border-white/60 shadow-[0_8px_32px_rgba(0,0,0,0.12)] rounded-3xl p-1.5 pointer-events-auto w-full max-w-[480px]">
|
<nav className="flex justify-between items-center bg-white/95 backdrop-blur-2xl border border-white/60 shadow-[0_8px_32px_rgba(0,0,0,0.12)] rounded-3xl p-1.5 pointer-events-auto w-full max-w-[480px]">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
||||||
? `/${currentLocale}${item.url}`
|
? `/${currentLocale}${item.url}`
|
||||||
@@ -113,7 +113,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
const innerContent = (
|
const innerContent = (
|
||||||
<>
|
<>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<motion.div
|
<m.div
|
||||||
layoutId="mobile-nav-active-bg"
|
layoutId="mobile-nav-active-bg"
|
||||||
className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10"
|
className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10"
|
||||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||||
@@ -121,19 +121,19 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative flex flex-col items-center justify-center z-10 w-full h-full">
|
<div className="relative flex flex-col items-center justify-center z-10 w-full h-full">
|
||||||
<motion.div
|
<m.div
|
||||||
whileTap={{ scale: 0.8 }}
|
whileTap={{ scale: 0.8 }}
|
||||||
animate={{ y: isActive ? -2 : 0 }}
|
animate={{ y: isActive ? -2 : 0 }}
|
||||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</motion.div>
|
</m.div>
|
||||||
<motion.span
|
<m.span
|
||||||
animate={{ y: isActive ? 0 : 2 }}
|
animate={{ y: isActive ? 0 : 2 }}
|
||||||
className="text-[9px] font-bold mt-1 tracking-wide"
|
className="text-[10px] font-bold mt-1 tracking-wide"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</motion.span>
|
</m.span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -144,7 +144,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
key={item.label}
|
key={item.label}
|
||||||
onClick={() => setIsFlyoutOpen(!isFlyoutOpen)}
|
onClick={() => setIsFlyoutOpen(!isFlyoutOpen)}
|
||||||
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none w-full focus:outline-none ${
|
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none w-full focus:outline-none ${
|
||||||
isActive ? 'text-primary' : 'text-neutral-600 hover:text-neutral-900'
|
isActive ? 'text-primary-dark' : 'text-neutral-dark hover:text-black'
|
||||||
}`}
|
}`}
|
||||||
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
||||||
>
|
>
|
||||||
@@ -159,7 +159,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
href={mappedUrl}
|
href={mappedUrl}
|
||||||
onClick={() => setIsFlyoutOpen(false)}
|
onClick={() => setIsFlyoutOpen(false)}
|
||||||
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none ${
|
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none ${
|
||||||
isActive ? 'text-primary' : 'text-neutral-600 hover:text-neutral-900'
|
isActive ? 'text-primary-dark' : 'text-neutral-dark hover:text-black'
|
||||||
}`}
|
}`}
|
||||||
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
||||||
>
|
>
|
||||||
@@ -173,7 +173,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isFlyoutOpen && (
|
{isFlyoutOpen && (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -181,7 +181,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[9997] md:hidden"
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[9997] md:hidden"
|
||||||
onClick={() => setIsFlyoutOpen(false)}
|
onClick={() => setIsFlyoutOpen(false)}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ y: '100%', opacity: 0 }}
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
exit={{ y: '100%', opacity: 0 }}
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
@@ -222,7 +222,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
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 }
|
||||||
|
);
|
||||||
7
components/providers/DynamicPageTransitionShutter.tsx
Normal file
7
components/providers/DynamicPageTransitionShutter.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
export const DynamicPageTransitionShutter = dynamic(
|
||||||
|
() => import('./PageTransitionShutter').then(mod => mod.PageTransitionShutter),
|
||||||
|
{ 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 { motion, AnimatePresence } from 'framer-motion';
|
import { m, AnimatePresence } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
|
|||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<motion.div
|
<m.div
|
||||||
key="initial-loader"
|
key="initial-loader"
|
||||||
initial={{ y: 0 }}
|
initial={{ y: 0 }}
|
||||||
exit={{ y: '-100%' }}
|
exit={{ y: '-100%' }}
|
||||||
@@ -46,7 +46,7 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
|
|||||||
{/* Deep Glow */}
|
{/* Deep Glow */}
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent opacity-80 mix-blend-screen" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent opacity-80 mix-blend-screen" />
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
||||||
@@ -70,7 +70,7 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
|
|||||||
<div className="absolute inset-0 bg-white/70" />
|
<div className="absolute inset-0 bg-white/70" />
|
||||||
|
|
||||||
{/* Sweeping intense white light */}
|
{/* Sweeping intense white light */}
|
||||||
<motion.div
|
<m.div
|
||||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
|
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
|
||||||
initial={{ left: '-150%' }}
|
initial={{ left: '-150%' }}
|
||||||
animate={{ left: '250%' }}
|
animate={{ left: '250%' }}
|
||||||
@@ -84,22 +84,22 @@ export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?:
|
|||||||
<span>System</span>
|
<span>System</span>
|
||||||
<span className="text-primary/80 animate-pulse">Online</span>
|
<span className="text-primary/80 animate-pulse">Online</span>
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
className="h-[2px] w-full bg-white/5 overflow-hidden relative rounded-full"
|
className="h-[2px] w-full bg-white/5 overflow-hidden relative rounded-full"
|
||||||
>
|
>
|
||||||
<motion.div
|
<m.div
|
||||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary-dark via-primary to-primary-light"
|
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary-dark via-primary to-primary-light"
|
||||||
initial={{ width: "0%" }}
|
initial={{ width: "0%" }}
|
||||||
animate={{ width: "100%" }}
|
animate={{ width: "100%" }}
|
||||||
transition={{ duration: 3.8, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 3.8, ease: [0.16, 1, 0.3, 1] }}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, 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';
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export function PageTransitionShutter() {
|
|||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isTransitioning && (
|
{isTransitioning && (
|
||||||
<motion.div
|
<m.div
|
||||||
key="shutter"
|
key="shutter"
|
||||||
initial={{ y: '-100%' }}
|
initial={{ y: '-100%' }}
|
||||||
animate={{ y: '0%' }}
|
animate={{ y: '0%' }}
|
||||||
@@ -29,7 +29,7 @@ export function PageTransitionShutter() {
|
|||||||
{/* Deep Glow inside the Shutter */}
|
{/* Deep Glow inside the Shutter */}
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent opacity-80 mix-blend-screen" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent opacity-80 mix-blend-screen" />
|
||||||
|
|
||||||
<motion.div
|
<m.div
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 1.05 }}
|
exit={{ opacity: 0, scale: 1.05 }}
|
||||||
@@ -54,7 +54,7 @@ export function PageTransitionShutter() {
|
|||||||
<div className="absolute inset-0 bg-white/70" />
|
<div className="absolute inset-0 bg-white/70" />
|
||||||
|
|
||||||
{/* Sweeping intense white light */}
|
{/* Sweeping intense white light */}
|
||||||
<motion.div
|
<m.div
|
||||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
|
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent skew-x-[-25deg]"
|
||||||
initial={{ left: '-150%' }}
|
initial={{ left: '-150%' }}
|
||||||
animate={{ left: '250%' }}
|
animate={{ left: '250%' }}
|
||||||
@@ -69,7 +69,7 @@ export function PageTransitionShutter() {
|
|||||||
(() => {
|
(() => {
|
||||||
const [, fromLang, toLang] = transitionMessage.split(':');
|
const [, fromLang, toLang] = transitionMessage.split(':');
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key="lang-switch-graphic"
|
key="lang-switch-graphic"
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
@@ -78,7 +78,7 @@ export function PageTransitionShutter() {
|
|||||||
>
|
>
|
||||||
{/* Old Language Flag - Fading out */}
|
{/* Old Language Flag - Fading out */}
|
||||||
<div className="w-20 h-20 sm:w-28 sm:h-28 rounded-[2rem] bg-white/5 border border-white/10 backdrop-blur-md flex items-center justify-center text-5xl sm:text-7xl relative overflow-hidden group grayscale opacity-50">
|
<div className="w-20 h-20 sm:w-28 sm:h-28 rounded-[2rem] bg-white/5 border border-white/10 backdrop-blur-md flex items-center justify-center text-5xl sm:text-7xl relative overflow-hidden group grayscale opacity-50">
|
||||||
<motion.div
|
<m.div
|
||||||
className="absolute inset-0 bg-white/10"
|
className="absolute inset-0 bg-white/10"
|
||||||
animate={{ scale: [1, 2], opacity: [0.5, 0] }}
|
animate={{ scale: [1, 2], opacity: [0.5, 0] }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity }}
|
transition={{ duration: 1.5, repeat: Infinity }}
|
||||||
@@ -88,7 +88,7 @@ export function PageTransitionShutter() {
|
|||||||
|
|
||||||
{/* Animated Arrow connecting them */}
|
{/* Animated Arrow connecting them */}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<motion.div
|
<m.div
|
||||||
animate={{ x: [0, 20, 0] }}
|
animate={{ x: [0, 20, 0] }}
|
||||||
transition={{ duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
|
transition={{ duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
|
||||||
className="text-primary drop-shadow-[0_0_15px_rgba(var(--color-primary),0.8)]"
|
className="text-primary drop-shadow-[0_0_15px_rgba(var(--color-primary),0.8)]"
|
||||||
@@ -97,7 +97,7 @@ export function PageTransitionShutter() {
|
|||||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
<polyline points="12 5 19 12 12 19"></polyline>
|
<polyline points="12 5 19 12 12 19"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</motion.div>
|
</m.div>
|
||||||
<div className="flex gap-2 opacity-50">
|
<div className="flex gap-2 opacity-50">
|
||||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--color-primary),1)]" />
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--color-primary),1)]" />
|
||||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--color-primary),1)]" style={{ animationDelay: '150ms' }} />
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--color-primary),1)]" style={{ animationDelay: '150ms' }} />
|
||||||
@@ -108,17 +108,17 @@ export function PageTransitionShutter() {
|
|||||||
{/* New Language Flag - Intense Glow */}
|
{/* New Language Flag - Intense Glow */}
|
||||||
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-[2.5rem] bg-primary/10 border border-primary/50 shadow-[0_0_80px_rgba(var(--color-primary),0.5)] backdrop-blur-xl flex items-center justify-center text-6xl sm:text-8xl relative overflow-hidden ring-4 ring-primary/30">
|
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-[2.5rem] bg-primary/10 border border-primary/50 shadow-[0_0_80px_rgba(var(--color-primary),0.5)] backdrop-blur-xl flex items-center justify-center text-6xl sm:text-8xl relative overflow-hidden ring-4 ring-primary/30">
|
||||||
<span className="relative z-10 drop-shadow-[0_0_15px_rgba(255,255,255,0.6)] leading-none">{toLang}</span>
|
<span className="relative z-10 drop-shadow-[0_0_15px_rgba(255,255,255,0.6)] leading-none">{toLang}</span>
|
||||||
<motion.div
|
<m.div
|
||||||
className="absolute inset-0 w-[200%] h-full bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg]"
|
className="absolute inset-0 w-[200%] h-full bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg]"
|
||||||
animate={{ x: ['-100%', '100%'] }}
|
animate={{ x: ['-100%', '100%'] }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<m.div
|
||||||
key="text-message"
|
key="text-message"
|
||||||
initial={{ opacity: 0, y: 15, scale: 0.9 }}
|
initial={{ opacity: 0, y: 15, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
@@ -126,13 +126,13 @@ export function PageTransitionShutter() {
|
|||||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
className="mt-10 mb-2 px-6 py-2.5 rounded-full border border-primary/40 bg-primary/10 shadow-[0_0_40px_rgba(var(--color-primary),0.3)] backdrop-blur-xl relative overflow-hidden flex items-center justify-center"
|
className="mt-10 mb-2 px-6 py-2.5 rounded-full border border-primary/40 bg-primary/10 shadow-[0_0_40px_rgba(var(--color-primary),0.3)] backdrop-blur-xl relative overflow-hidden flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<motion.div
|
<m.div
|
||||||
className="absolute inset-0 w-[200%] h-full bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg]"
|
className="absolute inset-0 w-[200%] h-full bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg]"
|
||||||
animate={{ x: ['-100%', '100%'] }}
|
animate={{ x: ['-100%', '100%'] }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||||
/>
|
/>
|
||||||
<span className="text-white font-heading tracking-[0.2em] uppercase text-xs sm:text-sm font-extrabold flex items-center gap-3 relative z-10 drop-shadow-[0_0_8px_rgba(255,255,255,0.5)]">
|
<span className="text-white font-heading tracking-[0.2em] uppercase text-xs sm:text-sm font-extrabold flex items-center gap-3 relative z-10 drop-shadow-[0_0_8px_rgba(255,255,255,0.5)]">
|
||||||
<motion.svg
|
<m.svg
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
|
||||||
xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-primary-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-primary-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||||
@@ -140,15 +140,15 @@ export function PageTransitionShutter() {
|
|||||||
<circle cx="12" cy="12" r="10"></circle>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||||
</motion.svg>
|
</m.svg>
|
||||||
{transitionMessage}
|
{transitionMessage}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</m.div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { motion, useMotionValue, useTransform, animate, useInView } from 'framer-motion';
|
import { useMotionValue, useTransform, animate, useInView, m } from 'framer-motion';
|
||||||
|
|
||||||
interface AnimatedCounterProps {
|
interface AnimatedCounterProps {
|
||||||
value: number;
|
value: number;
|
||||||
@@ -41,8 +41,8 @@ export function AnimatedCounter({
|
|||||||
}, [inView, value, duration, delay, count]);
|
}, [inView, value, duration, delay, count]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.span ref={ref} className={className}>
|
<m.span ref={ref} className={className}>
|
||||||
{prefix}<motion.span>{rounded}</motion.span>{suffix}
|
{prefix}<m.span>{rounded}</m.span>{suffix}
|
||||||
</motion.span>
|
</m.span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function TransitionLink({ children, href, onClick, transitionMessage, ...
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={href} onClick={handleClick} className={cn('cursor-pointer', props.className)} {...props}>
|
<Link href={href} onClick={handleClick} prefetch={props.prefetch ?? false} className={cn('cursor-pointer', props.className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
import { useInView, m } from 'framer-motion';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +73,7 @@ export function StratumLines({
|
|||||||
{LINES.map((line, i) => {
|
{LINES.map((line, i) => {
|
||||||
const isFromRight = placement.includes('right') ? !line.fromRight : line.fromRight;
|
const isFromRight = placement.includes('right') ? !line.fromRight : line.fromRight;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<m.div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
width: `${line.width}%`,
|
width: `${line.width}%`,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
|
|||||||
{/* Right Column: Imagery & Mission */}
|
{/* Right Column: Imagery & Mission */}
|
||||||
<div className="lg:col-span-7 relative">
|
<div className="lg:col-span-7 relative">
|
||||||
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
|
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
|
||||||
<img src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB im Einsatz" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
<ResponsiveImage src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB im Einsatz" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/40 to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/40 to-transparent"></div>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 w-full p-8 md:p-12">
|
<div className="absolute bottom-0 left-0 w-full p-8 md:p-12">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
|
|||||||
{/* Right Column: Imagery & Mission */}
|
{/* Right Column: Imagery & Mission */}
|
||||||
<div className="lg:col-span-7 relative">
|
<div className="lg:col-span-7 relative">
|
||||||
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
|
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
|
||||||
<img src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB in action" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
<ResponsiveImage src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB in action" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/40 to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/40 to-transparent"></div>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 w-full p-8 md:p-12">
|
<div className="absolute bottom-0 left-0 w-full p-8 md:p-12">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
dummy-polyfill.js
Normal file
1
dummy-polyfill.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default {};
|
||||||
@@ -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
10836
lh-report.json
10836
lh-report.json
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
2
lib/empty-polyfill.js
Normal file
2
lib/empty-polyfill.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Empty polyfill to trick Next.js into not loading legacy polyfills
|
||||||
|
export default {};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
|
|
||||||
const getSiteUrl = () => {
|
const getSiteUrl = () => {
|
||||||
if (process.env.CI) return 'http://etib.localhost';
|
|
||||||
return (config.baseUrl as string) || 'https://e-tib.com';
|
return (config.baseUrl as string) || 'https://e-tib.com';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
||||||
|
|
||||||
await this.notifications.notify({
|
await this.notifications.notify({
|
||||||
title: '🔥 Critical Error Captured',
|
title: '🔥 [E-TIB] Critical Error Captured',
|
||||||
message: `Error: ${errorMessage}${contextStr}`,
|
message: `Error: ${errorMessage}${contextStr}`,
|
||||||
priority: 7,
|
priority: 7,
|
||||||
});
|
});
|
||||||
@@ -92,7 +92,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
(level === 'error' || level === 'fatal' || message.includes('Route Not Found'))
|
(level === 'error' || level === 'fatal' || message.includes('Route Not Found'))
|
||||||
) {
|
) {
|
||||||
await this.notifications.notify({
|
await this.notifications.notify({
|
||||||
title: level === 'warning' ? '⚠️ Warning Captured' : '🔥 Critical Message Captured',
|
title: level === 'warning' ? '⚠️ [E-TIB] Warning Captured' : '🔥 [E-TIB] Critical Message Captured',
|
||||||
message: message,
|
message: message,
|
||||||
priority: level === 'warning' ? 5 : 7,
|
priority: level === 'warning' ? 5 : 7,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -48,7 +47,18 @@ const nextConfig = {
|
|||||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || process.env.npm_package_version || 'dev',
|
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || process.env.npm_package_version || 'dev',
|
||||||
},
|
},
|
||||||
// Prevent webpack from restarting when .env files are touched via Docker volume mount
|
// Prevent webpack from restarting when .env files are touched via Docker volume mount
|
||||||
webpack: (config, { dev }) => {
|
webpack: (config, { dev, isServer }) => {
|
||||||
|
// Completely strip Next.js forced legacy polyfills for modern browser targets
|
||||||
|
if (!isServer) {
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
'next/dist/build/polyfills/polyfill-module': false,
|
||||||
|
'next/dist/build/polyfills/polyfill-nomodule': false,
|
||||||
|
'core-js': false,
|
||||||
|
'regenerator-runtime': false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (dev) {
|
if (dev) {
|
||||||
config.watchOptions = {
|
config.watchOptions = {
|
||||||
...config.watchOptions,
|
...config.watchOptions,
|
||||||
@@ -439,7 +449,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
qualities: [25, 50, 75, 100],
|
qualities: [25, 50, 75, 100],
|
||||||
formats: ['image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@@ -539,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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user