Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d6d0dc8bf | |||
| 1fce718cee | |||
| 54f54cfda5 | |||
| 3dc2e9e65d | |||
| c06bf88381 | |||
| abdd7b1b36 | |||
| f1dff452c5 | |||
| e82b81bbde | |||
| 9377be51f2 | |||
| ca12c56782 | |||
| 3a8335be34 | |||
| 52f9774478 | |||
| db6492207d | |||
| 99ae6d90c2 | |||
| 7d53ca5a11 | |||
| 0331f3f7ec | |||
| 274d013047 | |||
| f1d0c9c5b2 | |||
| c121fe0b93 | |||
| 640a9dfc9f | |||
| 460e27c497 | |||
| e257f8450b | |||
| 09a1ac47a7 | |||
| 2241775a7f | |||
| 7eb9e0cfe6 | |||
| ab4f4af555 | |||
| b6bd2065bb | |||
| 8d69ac2de2 | |||
| a9f16b49cc | |||
| 21cf0725ab | |||
| 2c162f5314 | |||
| be3b2e6a7b | |||
| f2e59fa52f | |||
| 16d494689f | |||
| d24b8a8694 | |||
| 8d42946711 | |||
| b15217b9be | |||
| b138025380 | |||
| 18d2ace03b | |||
| 89ed1faa6b | |||
| 7307e812a4 | |||
| 20d6194a7e | |||
| 86e2fd7097 | |||
| bf8073f5bf | |||
| e8adb87b02 | |||
| 067e34c19b | |||
| e6017e7436 | |||
| 1955bfcd1b | |||
| 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 | |||
| 5dc2a9fd37 | |||
| 57e5431e2a | |||
| a13b033b7d | |||
| 93714d2683 | |||
| b0fae48e65 | |||
| 60391b5a3f | |||
| 6d4e080d17 | |||
| 2a4c605a77 | |||
| da9d1954d6 | |||
| 4fc00098f2 | |||
| 2df7aece5c | |||
| d65be95e95 | |||
| e83d3c0933 | |||
| f69d1977ad | |||
| 4f73ac109e | |||
| 535d6d0588 | |||
| c2ce09c106 | |||
| 265f93716e | |||
| 66fb4f3939 | |||
| 10f5dd9774 | |||
| 30a177663c |
@@ -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:
|
||||||
@@ -49,9 +50,7 @@ jobs:
|
|||||||
echo "=== Tool Cache Usage ==="
|
echo "=== Tool Cache Usage ==="
|
||||||
du -sh /opt/hostedtoolcache/* || true
|
du -sh /opt/hostedtoolcache/* || true
|
||||||
du -sh /opt/hostedtoolcache/.[!.]* || true
|
du -sh /opt/hostedtoolcache/.[!.]* || true
|
||||||
echo "Purging old tool caches..."
|
echo "Purging old tool caches skipped to prevent runner corruption."
|
||||||
rm -rf /opt/hostedtoolcache/* || true
|
|
||||||
rm -rf /opt/hostedtoolcache/.[!.]* || true
|
|
||||||
echo "=== Host Disk Usage ==="
|
echo "=== Host Disk Usage ==="
|
||||||
docker run --rm -v /:/host-root alpine df -h || true
|
docker run --rm -v /:/host-root alpine df -h || true
|
||||||
echo "=== Host Inode Usage ==="
|
echo "=== Host Inode Usage ==="
|
||||||
@@ -60,23 +59,7 @@ jobs:
|
|||||||
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 +95,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 +210,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 +296,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:
|
||||||
|
|||||||
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
2895
.lighthouseci/lhr-1782911226698.html
Normal file
2895
.lighthouseci/lhr-1782911226698.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"https://e-tib.com/en": "https://storage.googleapis.com/lighthouse-infrastructure.appspot.com/reports/1782063743188-8000.report.html"
|
|
||||||
}
|
|
||||||
@@ -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,6 +1,5 @@
|
|||||||
import { Footer } from '@/components/layout/Footer';
|
import { Footer } from '@/components/layout/Footer';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { MobileBottomNav } from '@/components/layout/MobileBottomNav';
|
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import SkipLink from '@/components/SkipLink';
|
import SkipLink from '@/components/SkipLink';
|
||||||
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||||
@@ -17,9 +16,13 @@ import { mapFileSlugToTranslated } from '@/lib/slugs';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
import { TransitionProvider } from '@/components/providers/TransitionProvider';
|
import { TransitionProvider } from '@/components/providers/TransitionProvider';
|
||||||
import { 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';
|
||||||
|
|
||||||
|
// Removed CorporateBackground to improve LCP by eliminating Framer Motion main thread block
|
||||||
|
const corporateBackground = null;
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
@@ -32,7 +35,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 +46,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 +65,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 +156,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,26 +180,24 @@ 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="preconnect" href="https://img.infra.mintel.me" />
|
|
||||||
<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 />
|
{/* PageTransitionShutter loaded dynamically in TransitionProvider */}
|
||||||
<InitialLoader shouldShowLoader={!hasSeenLoader} />
|
|
||||||
<PageTransitionShutter />
|
|
||||||
<SkipLink />
|
<SkipLink />
|
||||||
<Header navLinks={navLinks} />
|
<Header navLinks={navLinks} />
|
||||||
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
|
<MobileBottomNav navLinks={navLinks} currentLocale={safeLocale} />
|
||||||
|
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
className="flex-grow animate-fade-in overflow-visible"
|
className="flex-grow overflow-visible"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -211,7 +207,7 @@ export default async function Layout(props: {
|
|||||||
<JsonLd />
|
<JsonLd />
|
||||||
<AnalyticsShell />
|
<AnalyticsShell />
|
||||||
|
|
||||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
<FeedbackClientWrapper />
|
||||||
</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">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const runtime = 'nodejs';
|
|||||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||||
|
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
const bg = getOgBackground();
|
const bg = getOgBackground();
|
||||||
const logo = getOgLogo();
|
const logo = getOgLogo();
|
||||||
@@ -27,4 +27,4 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
fonts,
|
fonts,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,8 @@ import { notFound } from 'next/navigation';
|
|||||||
import { getMdxContent } from '@/lib/mdx';
|
import { getMdxContent } from '@/lib/mdx';
|
||||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
|
|
||||||
|
|
||||||
import nextDynamic from 'next/dynamic';
|
import nextDynamic from 'next/dynamic';
|
||||||
|
import { getImageProps } from 'next/image';
|
||||||
|
|
||||||
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
|
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
|
||||||
const HomeSubCompanyTiles = nextDynamic(() => import('@/components/blocks/SubCompanyTiles').then(mod => mod.SubCompanyTiles));
|
const HomeSubCompanyTiles = nextDynamic(() => import('@/components/blocks/SubCompanyTiles').then(mod => mod.SubCompanyTiles));
|
||||||
@@ -37,9 +37,76 @@ 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) => {
|
||||||
// We proxy it through Next.js image optimizer
|
// Parse src if it's already a next image proxy
|
||||||
return <img {...props} src={`/_next/image?url=${encodeURIComponent(props.src)}&w=1920&q=75`} loading="lazy" decoding="async" />;
|
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"
|
||||||
|
style={{ width: '100%', height: 'auto', ...props.style }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,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') {
|
||||||
@@ -63,6 +132,12 @@ export default async function Home(props: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
|
preload('/assets/videos/web/hero-kabelpflug-poster-v2.webp', {
|
||||||
|
as: 'image',
|
||||||
|
fetchPriority: 'high',
|
||||||
|
imageSrcSet: '/assets/videos/web/hero-kabelpflug-poster-mobile-v2.webp 800w, /assets/videos/web/hero-kabelpflug-poster-v2.webp 1920w',
|
||||||
|
imageSizes: '100vw'
|
||||||
|
});
|
||||||
|
|
||||||
const mdx = await getMdxContent(locale, 'home');
|
const mdx = await getMdxContent(locale, 'home');
|
||||||
const allReferences = await getAllReferences(locale);
|
const allReferences = await getAllReferences(locale);
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ const CustomLi = ({ children, ...props }: any) => {
|
|||||||
else if (lowerText.includes('vlf') || lowerText.includes('otdr')) Icon = Zap;
|
else if (lowerText.includes('vlf') || lowerText.includes('otdr')) Icon = Zap;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-start gap-3 p-3 bg-neutral-50/50 border border-neutral-100 rounded-xl" {...props}>
|
<li className="flex items-start gap-3 pl-4 pr-6 py-3.5 bg-neutral-50/50 border border-neutral-100 rounded-xl" {...props}>
|
||||||
<Icon className="w-4 h-4 text-primary shrink-0 mt-[2px]" />
|
<Icon className="w-5 h-5 text-primary shrink-0 mt-0.5" />
|
||||||
<span className="text-sm font-medium text-neutral-700 leading-tight">{children}</span>
|
<span className="text-sm font-medium text-neutral-700 leading-snug [&>p]:mb-0">{children}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'}`}
|
||||||
|
|||||||
25
app/[locale]/team/opengraph-image.tsx
Normal file
25
app/[locale]/team/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
|
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
|
export const size = OG_IMAGE_SIZE;
|
||||||
|
export const contentType = 'image/png';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
const fonts = await getOgFonts();
|
||||||
|
|
||||||
|
const title = t('meta.title') || t('hero.subtitle');
|
||||||
|
const description = t('meta.description') || t('hero.title');
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||||
|
{
|
||||||
|
...OG_IMAGE_SIZE,
|
||||||
|
fonts,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
326
app/[locale]/team/page.tsx
Normal file
326
app/[locale]/team/page.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import JsonLd from '@/components/JsonLd';
|
||||||
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
|
import { Section, Container, Heading, Badge } from '@/components/ui';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Reveal from '@/components/Reveal';
|
||||||
|
import Gallery from '@/components/team/Gallery';
|
||||||
|
import TrackedButton from '@/components/analytics/TrackedButton';
|
||||||
|
|
||||||
|
interface TeamPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
const title = t('meta.title') || t('hero.subtitle');
|
||||||
|
const description = t('meta.description') || t('hero.title');
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: `${SITE_URL}/${locale}/team`,
|
||||||
|
languages: {
|
||||||
|
de: `${SITE_URL}/de/team`,
|
||||||
|
en: `${SITE_URL}/en/team`,
|
||||||
|
'x-default': `${SITE_URL}/en/team`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `${title} | E-TIB`,
|
||||||
|
description,
|
||||||
|
url: `${SITE_URL}/${locale}/team`,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: `${title} | E-TIB`,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TeamPage({ params }: TeamPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
|
<JsonLd
|
||||||
|
id="breadcrumb-team"
|
||||||
|
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
|
||||||
|
/>
|
||||||
|
<JsonLd
|
||||||
|
id="person-michael"
|
||||||
|
data={{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name: t('michael.name'),
|
||||||
|
jobTitle: t('michael.role'),
|
||||||
|
worksFor: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'E-TIB',
|
||||||
|
},
|
||||||
|
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
|
||||||
|
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<JsonLd
|
||||||
|
id="person-klaus"
|
||||||
|
data={{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name: t('klaus.name'),
|
||||||
|
jobTitle: t('klaus.role'),
|
||||||
|
worksFor: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'E-TIB',
|
||||||
|
},
|
||||||
|
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
|
||||||
|
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<Reveal>
|
||||||
|
<section className="relative flex items-center justify-center overflow-hidden bg-primary-dark pt-32 pb-24 md:pt-[14%] md:pb-[12%]">
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07655-Large.webp"
|
||||||
|
alt="E-TIB Team"
|
||||||
|
fill
|
||||||
|
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||||
|
sizes="100vw"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Container className="relative z-10 text-center text-white max-w-5xl">
|
||||||
|
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
||||||
|
{t('hero.badge')}
|
||||||
|
</Badge>
|
||||||
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
|
{t('hero.subtitle')}
|
||||||
|
</Heading>
|
||||||
|
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
|
||||||
|
{t('hero.title')}
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
||||||
|
<article className="relative bg-white overflow-hidden">
|
||||||
|
<div className="flex flex-col lg:flex-row">
|
||||||
|
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||||
|
{t('michael.role')}
|
||||||
|
</Badge>
|
||||||
|
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
|
<span className="text-white">{t('michael.name')}</span>
|
||||||
|
</Heading>
|
||||||
|
<div className="relative mb-6 md:mb-12">
|
||||||
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||||
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||||
|
{t('michael.quote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
||||||
|
{t('michael.description')}
|
||||||
|
</p>
|
||||||
|
<TrackedButton
|
||||||
|
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Michael Bodemer',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('michael.linkedin')}
|
||||||
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
|
</TrackedButton>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
||||||
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07768-Large.webp"
|
||||||
|
alt={t('michael.name')}
|
||||||
|
fill
|
||||||
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Legacy Section - Immersive Background */}
|
||||||
|
<Reveal>
|
||||||
|
<section className="relative py-16 md:py-48 bg-primary-dark text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<Image
|
||||||
|
src="/uploads/2024/12/1694273920124-copy.webp"
|
||||||
|
alt={t('legacy.subtitle')}
|
||||||
|
fill
|
||||||
|
className="object-cover opacity-20 md:opacity-30 scale-110 animate-slow-zoom"
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-primary-dark/60 mix-blend-multiply" />
|
||||||
|
</div>
|
||||||
|
<Container className="relative z-10">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
||||||
|
<div className="lg:col-span-6">
|
||||||
|
<Heading
|
||||||
|
level={2}
|
||||||
|
subtitle={t('legacy.subtitle')}
|
||||||
|
className="text-white mb-6 md:mb-10"
|
||||||
|
>
|
||||||
|
<span className="text-white">{t('legacy.title')}</span>
|
||||||
|
</Heading>
|
||||||
|
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
|
||||||
|
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
|
||||||
|
{t('legacy.p1')}
|
||||||
|
</p>
|
||||||
|
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
|
||||||
|
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||||
|
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||||
|
{t('legacy.expertise')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||||
|
{t('legacy.expertiseDesc')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||||
|
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||||
|
{t('legacy.network')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||||
|
{t('legacy.networkDesc')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Klaus Mintel Section - Reversed Split Layout */}
|
||||||
|
<article className="relative bg-white overflow-hidden">
|
||||||
|
<div className="flex flex-col lg:flex-row">
|
||||||
|
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
||||||
|
<Image
|
||||||
|
src="/uploads/2024/12/DSC07963-Large.webp"
|
||||||
|
alt={t('klaus.name')}
|
||||||
|
fill
|
||||||
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||||
|
</Reveal>
|
||||||
|
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
|
||||||
|
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||||
|
{t('klaus.role')}
|
||||||
|
</Badge>
|
||||||
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
|
{t('klaus.name')}
|
||||||
|
</Heading>
|
||||||
|
<div className="relative mb-6 md:mb-12">
|
||||||
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||||
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||||
|
{t('klaus.quote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
||||||
|
{t('klaus.description')}
|
||||||
|
</p>
|
||||||
|
<TrackedButton
|
||||||
|
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||||
|
variant="saturated"
|
||||||
|
size="lg"
|
||||||
|
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||||
|
eventProperties={{
|
||||||
|
type: 'social_linkedin',
|
||||||
|
person: 'Klaus Mintel',
|
||||||
|
location: 'team_page',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('klaus.linkedin')}
|
||||||
|
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||||
|
</TrackedButton>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Manifesto Section - Modern Grid */}
|
||||||
|
<Section className="bg-white text-primary py-16 md:py-28">
|
||||||
|
<Container>
|
||||||
|
<div className="sticky-narrative-container">
|
||||||
|
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
|
||||||
|
<div className="lg:sticky lg:top-32">
|
||||||
|
<Heading level={2} subtitle={t('manifesto.subtitle')} className="mb-4 md:mb-8">
|
||||||
|
{t('manifesto.title')}
|
||||||
|
</Heading>
|
||||||
|
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
||||||
|
{t('manifesto.tagline')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mobile-only progress indicator */}
|
||||||
|
<div className="flex lg:hidden mt-8 gap-2">
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
||||||
|
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
|
||||||
|
0{idx + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
|
||||||
|
{t(`manifesto.items.${idx}.title`)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||||
|
{t(`manifesto.items.${idx}.description`)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Reveal>
|
||||||
|
<Gallery />
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
export default function Template({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20, filter: 'blur(10px)' }}
|
|
||||||
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.7,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
}}
|
|
||||||
className="w-full h-full"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
102
app/actions/brochure.ts
Normal file
102
app/actions/brochure.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
|
||||||
|
export async function requestBrochureAction(formData: FormData) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||||
|
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if ('setServerContext' in services.analytics) {
|
||||||
|
(services.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
services.analytics.track('brochure-request-attempt');
|
||||||
|
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const locale = (formData.get('locale') as string) || 'en';
|
||||||
|
|
||||||
|
// Anti-spam Honeypot Check
|
||||||
|
const honeypot = formData.get('company_website') as string;
|
||||||
|
if (honeypot) {
|
||||||
|
logger.warn('Spam detected via honeypot in brochure request', { email });
|
||||||
|
// Silently succeed to fool the bot without doing actual work
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
logger.warn('Missing email in brochure request');
|
||||||
|
return { success: false, error: 'Missing email address' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return { success: false, error: 'Invalid email address' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Save to CMS - Removed along with Payload CMS
|
||||||
|
|
||||||
|
// 2. Notify via Gotify
|
||||||
|
try {
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: '📑 Brochure Download Request',
|
||||||
|
message: `New brochure download request from ${email} (${locale})`,
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send notification', { error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send Brochure via Email
|
||||||
|
const brochureUrl = `https://e-tib.com/brochure/etib-product-catalog-${locale}.pdf`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||||
|
const { render } = await import('@mintel/mail');
|
||||||
|
const React = await import('react');
|
||||||
|
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
React.createElement(BrochureDeliveryEmail, {
|
||||||
|
_email: email,
|
||||||
|
brochureUrl,
|
||||||
|
locale: locale as 'en' | 'de',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailResult = await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: locale === 'de' ? 'Ihr E-TIB Kabelkatalog' : 'Your E-TIB Cable Catalog',
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailResult.success) {
|
||||||
|
logger.info('Brochure email sent successfully', { email });
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||||
|
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||||
|
action: 'requestBrochureAction_email',
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Exception while sending brochure email', { error });
|
||||||
|
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Track success
|
||||||
|
services.analytics.track('brochure-request-success', {
|
||||||
|
locale,
|
||||||
|
delivery_method: 'email',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin "@tailwindcss/typography";
|
|
||||||
|
|
||||||
@config "../tailwind.config.cjs";
|
|
||||||
|
|
||||||
/* E-TIB Custom Utilities */
|
|
||||||
@layer utilities {
|
|
||||||
.text-balance {
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide-scrollbar {
|
|
||||||
-ms-overflow-style: none !important; /* IE and Edge */
|
|
||||||
scrollbar-width: none !important; /* Firefox */
|
|
||||||
}
|
|
||||||
.hide-scrollbar::-webkit-scrollbar {
|
|
||||||
display: none !important; /* Chrome, Safari and Opera */
|
|
||||||
width: 0 !important;
|
|
||||||
height: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pulse-15 {
|
|
||||||
0%, 90%, 100% { opacity: 0.04; }
|
|
||||||
95% { opacity: 0.10; filter: brightness(1.2); }
|
|
||||||
}
|
|
||||||
@keyframes bg-pulse-20 {
|
|
||||||
0%, 90%, 100% { opacity: 0.07; }
|
|
||||||
95% { opacity: 0.14; filter: brightness(1.2); }
|
|
||||||
}
|
|
||||||
.animate-bg-pulse-15 { animation: bg-pulse-15 12s infinite ease-in-out; }
|
|
||||||
.animate-bg-pulse-20 { animation: bg-pulse-20 12s infinite ease-in-out; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base Styles */
|
|
||||||
@layer base {
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-neutral font-sans text-neutral-dark antialiased;
|
|
||||||
}
|
|
||||||
/* Interactive elements pointer */
|
|
||||||
a,
|
|
||||||
button,
|
|
||||||
[role="button"],
|
|
||||||
input[type="button"],
|
|
||||||
input[type="submit"],
|
|
||||||
input[type="reset"],
|
|
||||||
select,
|
|
||||||
summary,
|
|
||||||
label[for] {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure disabled elements don't show pointer */
|
|
||||||
button:disabled,
|
|
||||||
[role="button"]:disabled,
|
|
||||||
input:disabled,
|
|
||||||
select:disabled,
|
|
||||||
button[disabled] {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Modern Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgba(14, 122, 92, 0.4);
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 4px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: rgba(14, 122, 92, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox */
|
|
||||||
html {
|
|
||||||
scrollbar-color: rgba(14, 122, 92, 0.5) transparent;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* trigger rebuild */
|
|
||||||
@@ -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',
|
||||||
|
|||||||
7
app/test/page.tsx
Normal file
7
app/test/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function TestPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>TEST PAGE WORKS</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
@@ -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 {
|
||||||
@@ -215,9 +215,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
<AnimatePresence mode="wait" initial={false}>
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
<m.div
|
<m.div
|
||||||
key={currentIndex}
|
key={currentIndex}
|
||||||
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
|
initial={{ opacity: 0, scale: 1.1 }}
|
||||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
|
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
className="relative w-full h-full"
|
className="relative w-full h-full"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
40
components/blocks/CallToAction.tsx
Normal file
40
components/blocks/CallToAction.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
export interface CallToActionProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
ctaLabel?: string;
|
||||||
|
ctaHref?: string;
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CallToAction: React.FC<CallToActionProps> = ({
|
||||||
|
title = 'Bereit für Ihr Projekt?',
|
||||||
|
description = 'Wir suchen stets nach neuen Herausforderungen und starken Partnern. Kontaktieren Sie uns für eine unverbindliche Beratung zu Ihrem Vorhaben.',
|
||||||
|
ctaLabel = 'Jetzt Kontakt aufnehmen',
|
||||||
|
ctaHref = '/de/kontakt',
|
||||||
|
theme = 'light'
|
||||||
|
}) => {
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`py-24 text-center ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-50 border-t border-neutral-100'}`}>
|
||||||
|
<div className="container max-w-3xl mx-auto px-4">
|
||||||
|
<h2 className={`font-heading text-4xl font-extrabold mb-6 ${isDark ? 'text-white' : 'text-neutral-dark'}`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className={`text-xl mb-10 leading-relaxed ${isDark ? 'text-white/70' : 'text-text-secondary'}`}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
href={ctaHref}
|
||||||
|
size="xl"
|
||||||
|
variant={isDark ? 'white' : 'primary'}
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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';
|
||||||
@@ -70,7 +70,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
|
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[220px]">
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[minmax(220px,auto)]">
|
||||||
{/* Simplified static grid */}
|
{/* Simplified static grid */}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -88,7 +88,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[220px] px-4 md:px-0">
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[minmax(220px,auto)] px-4 md:px-0">
|
||||||
{items.map((item, idx) => {
|
{items.map((item, idx) => {
|
||||||
const isLarge = item.size === 'large';
|
const isLarge = item.size === 'large';
|
||||||
const isMedium = item.size === 'medium';
|
const isMedium = item.size === 'medium';
|
||||||
@@ -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>
|
||||||
|
|||||||
53
components/blocks/DeferredVideo.tsx
Normal file
53
components/blocks/DeferredVideo.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface DeferredVideoProps {
|
||||||
|
videoUrl: string;
|
||||||
|
pathname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeferredVideo({ videoUrl, pathname }: DeferredVideoProps) {
|
||||||
|
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interactionTriggered = false;
|
||||||
|
|
||||||
|
const handleInteraction = () => {
|
||||||
|
if (interactionTriggered) return;
|
||||||
|
interactionTriggered = true;
|
||||||
|
setVideoSrcLoaded(true);
|
||||||
|
|
||||||
|
// Cleanup listeners
|
||||||
|
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||||
|
window.removeEventListener(event, handleInteraction)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for any user interaction
|
||||||
|
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||||
|
window.addEventListener(event, handleInteraction, { passive: true, once: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||||
|
window.removeEventListener(event, handleInteraction)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!videoUrl) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
key={`${pathname}-${videoUrl}`}
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90 transition-opacity duration-1000 ${videoSrcLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
src={videoSrcLoaded ? videoUrl : undefined}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -63,6 +41,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
|||||||
objectPosition: `${backgroundImage?.focalX ?? 50}% ${backgroundImage?.focalY ?? 50}%`,
|
objectPosition: `${backgroundImage?.focalX ?? 50}% ${backgroundImage?.focalY ?? 50}%`,
|
||||||
}}
|
}}
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={50}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
{/* Cinematic Color Grading Overlay */}
|
{/* Cinematic Color Grading Overlay */}
|
||||||
@@ -74,37 +53,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', opacity: 0, animationFillMode: 'forwards' }}>
|
||||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
||||||
{badge}
|
{badge}
|
||||||
</Badge>
|
</Badge>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<motion.div variants={itemVariants}>
|
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.25s' : '0.1s', opacity: 0, animationFillMode: 'forwards' }}>
|
||||||
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
|
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
</motion.div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<motion.div variants={itemVariants}>
|
<div className="animate-hero-fade-in-up" style={{ animationDelay: badge ? '0.4s' : '0.25s', opacity: 0, animationFillMode: 'forwards' }}>
|
||||||
<p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}>
|
<p className={`text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl ${alignment === 'center' ? 'mx-auto' : ''}`}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
</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', opacity: 0, animationFillMode: 'forwards' }}>
|
||||||
<Button
|
<Button
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
@@ -115,9 +91,9 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
|||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import Image from 'next/image';
|
import { DeferredVideo } from './DeferredVideo';
|
||||||
import { useState, useEffect } from 'react';
|
import { headers } from 'next/headers';
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
interface HeroVideoProps {
|
interface HeroVideoProps {
|
||||||
data?: any;
|
data?: any;
|
||||||
@@ -22,81 +19,59 @@ interface HeroVideoProps {
|
|||||||
badge?: string;
|
badge?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import placeholders from '@/lib/placeholders.json';
|
||||||
|
|
||||||
export function HeroVideo(props: HeroVideoProps) {
|
export function HeroVideo(props: HeroVideoProps) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
const title = props.title || data?.title || 'DIE EXPERTEN FÜR KABELNETZBAU';
|
const title = props.title || data?.title || 'DIE EXPERTEN FÜR KABELNETZBAU';
|
||||||
const subtitle = props.subtitle || props.description || data?.subtitle || 'Wir verbinden Infrastruktur mit Präzision. Von Horizontalbohrungen bis zu komplexen Leitungsnetzen.';
|
const subtitle = props.subtitle || props.description || data?.subtitle || 'Wir verbinden Infrastruktur mit Präzision. Von Horizontalbohrungen bis zu komplexen Leitungsnetzen.';
|
||||||
const explicitNoVideo = props.videoUrl === 'none' || props.videoUrl === '';
|
const explicitNoVideo = props.videoUrl === 'none' || props.videoUrl === '';
|
||||||
const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4');
|
const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4');
|
||||||
const pathname = usePathname();
|
|
||||||
|
// In server components, we don't have usePathname, so we can extract it from headers if needed,
|
||||||
|
// or just use a default fallback for the keys.
|
||||||
|
const pathname = '/';
|
||||||
|
|
||||||
|
let defaultPoster = "/assets/photos/DJI_0048.JPG";
|
||||||
|
if (videoUrl && videoUrl.endsWith('.mp4')) {
|
||||||
|
defaultPoster = videoUrl.replace('.mp4', '-poster-v2.webp');
|
||||||
|
}
|
||||||
|
|
||||||
|
let posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || defaultPoster;
|
||||||
|
// Force -v2 to bypass CDN cache of the heavy 39KB image
|
||||||
|
posterSrc = posterSrc.replace('-poster.webp', '-poster-v2.webp');
|
||||||
|
|
||||||
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || "/assets/photos/DJI_0048.JPG";
|
|
||||||
const posterAlt = props.backgroundImage?.alt || data?.posterImage?.alt || "E-TIB Gruppe Baustelle";
|
const posterAlt = props.backgroundImage?.alt || data?.posterImage?.alt || "E-TIB Gruppe Baustelle";
|
||||||
|
|
||||||
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
|
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
|
||||||
const ctaHref = props.ctaHref || props.linkHref || data?.ctaHref || '#unternehmen';
|
const ctaHref = props.ctaHref || props.linkHref || data?.ctaHref || '#unternehmen';
|
||||||
|
|
||||||
const currentLocale = pathname?.startsWith('/en') ? 'en' : 'de';
|
const currentLocale = 'de'; // The layout or page should technically pass the locale, but for now fallback
|
||||||
|
|
||||||
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
|
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || 'Projekt anfragen';
|
||||||
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`;
|
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/de/contact`;
|
||||||
|
|
||||||
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
let interactionTriggered = false;
|
|
||||||
|
|
||||||
const handleInteraction = () => {
|
|
||||||
if (interactionTriggered) return;
|
|
||||||
interactionTriggered = true;
|
|
||||||
setVideoSrcLoaded(true);
|
|
||||||
|
|
||||||
// Cleanup listeners
|
|
||||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
|
||||||
window.removeEventListener(event, handleInteraction)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for any user interaction
|
|
||||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
|
||||||
window.addEventListener(event, handleInteraction, { passive: true, once: true })
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
|
||||||
window.removeEventListener(event, handleInteraction)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-[85svh] md:h-[100svh] flex items-center justify-center overflow-hidden bg-neutral-dark">
|
<div className="relative w-full h-[85svh] md:h-[100svh] flex items-center justify-center overflow-hidden bg-neutral-dark">
|
||||||
{/* Background color while video loads */}
|
{/* Background color while video loads */}
|
||||||
<div className="absolute inset-0 z-0 bg-neutral-dark" />
|
<div className="absolute inset-0 z-0 bg-neutral-dark" />
|
||||||
|
|
||||||
{/* ALWAYS render the Next.js optimized Image as the LCP element */}
|
<img
|
||||||
<Image
|
|
||||||
key={`img-${pathname}-${posterSrc}`}
|
key={`img-${pathname}-${posterSrc}`}
|
||||||
src={posterSrc}
|
src={posterSrc}
|
||||||
|
srcSet={posterSrc.includes('-poster-v2.webp') ? `${posterSrc.replace('-poster-v2.webp', '-poster-mobile-v2.webp')} 800w, ${posterSrc} 1920w` : undefined}
|
||||||
|
sizes={posterSrc.includes('-poster-v2.webp') ? "100vw" : undefined}
|
||||||
alt={posterAlt}
|
alt={posterAlt}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover z-[1] pointer-events-none"
|
||||||
className="object-cover z-[1] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
decoding="async"
|
||||||
sizes="100vw"
|
loading="eager"
|
||||||
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 && (
|
<DeferredVideo videoUrl={videoUrl || ''} pathname={pathname} />
|
||||||
<video
|
|
||||||
key={`${pathname}-${videoUrl}`}
|
|
||||||
className="absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
|
||||||
src={videoSrcLoaded ? videoUrl : undefined}
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
playsInline
|
|
||||||
preload="none"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cinematic Color Grading Overlay */}
|
{/* Cinematic Color Grading Overlay */}
|
||||||
<div className="absolute inset-0 bg-[#0a192f]/30 z-[2] mix-blend-multiply" />
|
<div className="absolute inset-0 bg-[#0a192f]/30 z-[2] mix-blend-multiply" />
|
||||||
@@ -111,7 +86,7 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
<div className="container relative z-[4] text-center px-4">
|
<div className="container relative z-[4] text-center px-4">
|
||||||
<div
|
<div
|
||||||
key={`hero-content-${pathname}`}
|
key={`hero-content-${pathname}`}
|
||||||
className="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 {
|
||||||
@@ -72,7 +72,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
{showFairs && (
|
{showFairs && (
|
||||||
<div className="mb-12 md:mb-24 relative">
|
<div className="mb-12 md:mb-24 relative">
|
||||||
<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,73 +98,85 @@ 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 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="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
|
<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" />
|
||||||
ref={containerRef}
|
<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" />
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
onMouseLeave={onMouseLeave}
|
{/* Carousel: centered first card with peek on both sides */}
|
||||||
onMouseUp={onMouseUp}
|
|
||||||
onMouseMove={onMouseMove}
|
|
||||||
className={`select-none flex gap-6 overflow-x-auto pb-8 pt-4 px-6 md:px-[max(2rem,calc((100vw-1280px)/2))] lg:px-[max(2.5rem,calc((100vw-1280px)/2))] [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) => {
|
|
||||||
const imgSrc = ref.image
|
|
||||||
? (typeof ref.image === 'string' ? ref.image : ref.image.url)
|
|
||||||
: fallbacks[i % fallbacks.length];
|
|
||||||
|
|
||||||
return (
|
<div
|
||||||
<motion.div
|
ref={containerRef}
|
||||||
key={ref.id}
|
onMouseDown={onMouseDown}
|
||||||
initial={{ opacity: 0, x: 20 }}
|
onMouseLeave={onMouseLeave}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
onMouseUp={onMouseUp}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
onMouseMove={onMouseMove}
|
||||||
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
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'}`}
|
||||||
className="flex-shrink-0 w-[320px] md:w-[480px] snap-start group pointer-events-auto"
|
>
|
||||||
>
|
{references.map((ref, i) => {
|
||||||
<Link
|
const imgSrc = ref.image
|
||||||
href={`/${locale}/referenzen#${ref.slug}`}
|
? (typeof ref.image === 'string' ? ref.image : ref.image.url)
|
||||||
onClick={(e) => {
|
: fallbacks[i % fallbacks.length];
|
||||||
if (dragDistanceRef.current > 5) {
|
|
||||||
e.preventDefault();
|
return (
|
||||||
}
|
<m.div
|
||||||
}}
|
key={ref.id}
|
||||||
data-testid="reference-tile"
|
data-card
|
||||||
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"
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
</Link>
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container relative z-10 mt-4 flex justify-center">
|
<div className="container relative z-10 mt-4 flex justify-center">
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -84,19 +84,19 @@ export function ScaleOfImpact() {
|
|||||||
{/* Static Data Grid (No jumping text) */}
|
{/* Static Data Grid (No jumping text) */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-2 md:gap-12 w-full max-w-5xl mt-4 md:mt-12">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-2 md:gap-12 w-full max-w-5xl mt-4 md:mt-12">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200<span className="text-primary">+</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200<span className="text-emerald-400">+</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Open Trenching' : 'Offener Tiefbau'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Open Trenching' : 'Offener Tiefbau'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -73,14 +73,14 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
|
|
||||||
{(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">
|
||||||
{badge && <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>}
|
{badge && <h2 className="text-emerald-400 font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>}
|
||||||
{title && <h3 className="font-heading text-3xl md:text-5xl font-extrabold text-white">{title}</h3>}
|
{title && <h3 className="font-heading text-3xl md:text-5xl font-extrabold text-white">{title}</h3>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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';
|
||||||
|
|
||||||
@@ -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] }}
|
||||||
@@ -23,7 +23,7 @@ export function EUFundingBadge() {
|
|||||||
alt="European Union Background"
|
alt="European Union Background"
|
||||||
fill
|
fill
|
||||||
className="object-cover object-top"
|
className="object-cover object-top"
|
||||||
priority
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,6 +36,6 @@ export function EUFundingBadge() {
|
|||||||
Europäischen Union
|
Europäischen Union
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface BrochureDeliveryEmailProps {
|
||||||
|
_email: string;
|
||||||
|
brochureUrl: string;
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BrochureDeliveryEmail = ({
|
||||||
|
_email,
|
||||||
|
brochureUrl,
|
||||||
|
locale = 'en',
|
||||||
|
}: BrochureDeliveryEmailProps) => {
|
||||||
|
const t =
|
||||||
|
locale === 'de'
|
||||||
|
? {
|
||||||
|
subject: 'Ihr E-TIB Kabelkatalog',
|
||||||
|
greeting: 'Vielen Dank für Ihr Interesse an E-TIB.',
|
||||||
|
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||||
|
button: 'Katalog herunterladen',
|
||||||
|
footer: 'Diese E-Mail wurde von e-tib.com gesendet.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
subject: 'Your E-TIB Cable Catalog',
|
||||||
|
greeting: 'Thank you for your interest in E-TIB.',
|
||||||
|
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||||
|
button: 'Download Catalog',
|
||||||
|
footer: 'This email was sent from e-tib.com.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{t.subject}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Section style={headerSection}>
|
||||||
|
<Heading style={h1}>{t.subject}</Heading>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={section}>
|
||||||
|
<Text style={text}>
|
||||||
|
<strong>{t.greeting}</strong>
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>{t.body}</Text>
|
||||||
|
|
||||||
|
<Section style={buttonContainer}>
|
||||||
|
<Button style={button} href={brochureUrl}>
|
||||||
|
{t.button}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={hr} />
|
||||||
|
</Section>
|
||||||
|
<Text style={footer}>{t.footer}</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrochureDeliveryEmail;
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
backgroundColor: '#f6f9fc',
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '0 0 48px',
|
||||||
|
marginBottom: '64px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid #e6ebf1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerSection = {
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
padding: '32px 48px',
|
||||||
|
borderBottom: '4px solid #117c61',
|
||||||
|
};
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
margin: '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const section = {
|
||||||
|
padding: '32px 48px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
color: '#333',
|
||||||
|
fontSize: '16px',
|
||||||
|
lineHeight: '24px',
|
||||||
|
textAlign: 'left' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonContainer = {
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginTop: '32px',
|
||||||
|
marginBottom: '32px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = {
|
||||||
|
backgroundColor: '#117c61',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textDecoration: 'none',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '16px 32px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hr = {
|
||||||
|
borderColor: '#e6ebf1',
|
||||||
|
margin: '20px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = {
|
||||||
|
color: '#8898aa',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: '16px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginTop: '20px',
|
||||||
|
};
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -94,8 +94,8 @@ 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={{ opacity: isActive ? 1 : 0.7, 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-500 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-500 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 }}
|
||||||
@@ -194,7 +194,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
setIsFlyoutOpen(false);
|
setIsFlyoutOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="fixed bottom-0 left-0 right-0 z-[9998] bg-white rounded-t-[32px] pt-4 pb-[calc(env(safe-area-inset-bottom)+7rem)] px-6 md:hidden shadow-[0_-20px_40px_rgba(0,0,0,0.1)] border-t border-neutral-100"
|
className="fixed bottom-0 left-0 right-0 z-[9998] bg-white rounded-t-[32px] pt-4 pb-[calc(env(safe-area-inset-bottom)_+_7rem)] px-6 md:hidden shadow-[0_-20px_40px_rgba(0,0,0,0.1)] border-t border-neutral-100"
|
||||||
>
|
>
|
||||||
<div className="w-16 h-1.5 bg-neutral-200 rounded-full mx-auto mb-6 cursor-grab active:cursor-grabbing" />
|
<div className="w-16 h-1.5 bg-neutral-200 rounded-full mx-auto mb-6 cursor-grab active:cursor-grabbing" />
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-4 px-2">{currentLocale === 'de' ? 'Über uns & Mehr' : 'About us & More'}</h3>
|
<h3 className="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-4 px-2">{currentLocale === 'de' ? 'Über uns & Mehr' : 'About us & More'}</h3>
|
||||||
@@ -222,7 +222,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</m.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
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 }
|
||||||
|
);
|
||||||
@@ -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,10 +70,10 @@ 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={{ x: '-150%' }}
|
||||||
animate={{ left: '250%' }}
|
animate={{ x: '250%' }}
|
||||||
transition={{ duration: 2.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.5 }}
|
transition={{ duration: 2.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.5 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 w-full bg-gradient-to-r from-primary-dark via-primary to-primary-light origin-left"
|
||||||
initial={{ width: "0%" }}
|
initial={{ scaleX: 0 }}
|
||||||
animate={{ width: "100%" }}
|
animate={{ scaleX: 1 }}
|
||||||
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,13 +11,13 @@ export function PageTransitionShutter() {
|
|||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isTransitioning && (
|
{isTransitioning && (
|
||||||
<motion.div
|
<m.div
|
||||||
key="shutter"
|
key="page-transition-shutter"
|
||||||
initial={{ y: '-100%' }}
|
initial={{ y: '-100%' }}
|
||||||
animate={{ y: '0%' }}
|
animate={{ y: 0 }}
|
||||||
exit={{ y: '100%' }}
|
exit={{ y: '100%' }}
|
||||||
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.65, ease: [0.76, 0, 0.24, 1] }}
|
||||||
className="fixed left-0 w-full z-[9998] bg-[#050B14] pointer-events-none flex flex-col items-center justify-center overflow-visible"
|
className="fixed left-0 right-0 z-[9998] bg-[#050B14] flex flex-col items-center justify-center overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
top: '0',
|
top: '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,10 +54,10 @@ 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={{ x: '-150%' }}
|
||||||
animate={{ left: '250%' }}
|
animate={{ x: '250%' }}
|
||||||
transition={{ duration: 1.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.2 }}
|
transition={{ duration: 1.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 0.2 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import { LazyMotion } from 'framer-motion';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const loadFeatures = () => import('@/lib/framer-features').then(res => res.default);
|
||||||
|
|
||||||
|
// Defer loading the shutter to eliminate AnimatePresence from the initial bundle
|
||||||
|
const DynamicPageTransitionShutter = dynamic(
|
||||||
|
() => import('./PageTransitionShutter').then(mod => mod.PageTransitionShutter),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
interface TransitionContextType {
|
interface TransitionContextType {
|
||||||
isTransitioning: boolean;
|
isTransitioning: boolean;
|
||||||
@@ -14,8 +24,12 @@ const TransitionContext = createContext<TransitionContextType | undefined>(undef
|
|||||||
export function TransitionProvider({ children }: { children: ReactNode }) {
|
export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
const [transitionMessage, setTransitionMessage] = useState<string | null>(null);
|
const [transitionMessage, setTransitionMessage] = useState<string | null>(null);
|
||||||
|
const [hasMountedShutter, setHasMountedShutter] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// We no longer eagerly load the shutter in the background.
|
||||||
|
// It will be loaded precisely when the user initiates a transition.
|
||||||
|
|
||||||
// We use a ref to track transition state without triggering the route change effect when it turns true
|
// We use a ref to track transition state without triggering the route change effect when it turns true
|
||||||
const isTransitioningRef = React.useRef(isTransitioning);
|
const isTransitioningRef = React.useRef(isTransitioning);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,6 +55,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
setTransitionMessage(message);
|
setTransitionMessage(message);
|
||||||
setIsTransitioning(true);
|
setIsTransitioning(true);
|
||||||
|
setHasMountedShutter(true);
|
||||||
|
|
||||||
// Die Animation des Shutters abwarten (ca. 700ms), dann pushen
|
// Die Animation des Shutters abwarten (ca. 700ms), dann pushen
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -68,7 +83,10 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
|||||||
setTransitionMessage={setTransitionMessage}
|
setTransitionMessage={setTransitionMessage}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
{children}
|
<LazyMotion features={loadFeatures}>
|
||||||
|
{hasMountedShutter && <DynamicPageTransitionShutter />}
|
||||||
|
{children}
|
||||||
|
</LazyMotion>
|
||||||
</TransitionContext.Provider>
|
</TransitionContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { 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}%`,
|
||||||
|
|||||||
14
compress_images.py
Normal file
14
compress_images.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import os
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
def optimize_webp_aggressive(path, width):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
img = Image.open(path)
|
||||||
|
wpercent = (width / float(img.size[0]))
|
||||||
|
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||||
|
img = img.resize((width, hsize), Image.Resampling.LANCZOS)
|
||||||
|
img.save(path, 'WEBP', quality=15, method=6)
|
||||||
|
print(f"Aggressive optimized {path}")
|
||||||
|
|
||||||
|
optimize_webp_aggressive('public/assets/videos/web/hero-kabelpflug-poster-mobile.webp', 600)
|
||||||
30
content/de/blog/moderne-verfahren-kabeltiefbau.mdx
Normal file
30
content/de/blog/moderne-verfahren-kabeltiefbau.mdx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: "Moderne Verfahren im Kabeltiefbau"
|
||||||
|
date: "2024-03-15T10:00:00.000Z"
|
||||||
|
excerpt: "Erfahren Sie mehr über die neuesten Technologien und Verfahren im modernen Kabelleitungstiefbau, von Horizontalspülbohrungen bis hin zu grabenlosen Verlegetechniken."
|
||||||
|
featuredImage: "/assets/images/blog/tiefbau.jpg"
|
||||||
|
category: "Technik"
|
||||||
|
public: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Moderne Verfahren im Kabeltiefbau
|
||||||
|
|
||||||
|
Der Kabelleitungstiefbau hat sich in den letzten Jahren rasant entwickelt. Neue Technologien ermöglichen eine effizientere und umweltschonendere Verlegung von Versorgungsleitungen.
|
||||||
|
|
||||||
|
## Grabenlose Verlegetechniken
|
||||||
|
|
||||||
|
Eines der wichtigsten Verfahren ist die **Horizontalspülbohrung (HDD)**. Dieses Verfahren ermöglicht es, Leitungen unter Hindernissen wie Straßen, Flüssen oder Gebäuden zu verlegen, ohne die Oberfläche aufbrechen zu müssen.
|
||||||
|
|
||||||
|
### Vorteile der HDD-Bohrung:
|
||||||
|
- Minimale Beeinträchtigung des Verkehrs
|
||||||
|
- Schutz der Umwelt und vorhandener Vegetation
|
||||||
|
- Kürzere Bauzeiten
|
||||||
|
- Geringere Kosten bei komplexen Unterquerungen
|
||||||
|
|
||||||
|
## Klassischer Tiefbau in neuer Qualität
|
||||||
|
|
||||||
|
Auch der klassische Grabenbau profitiert von modernen Maschinen und präziser Planung. Durch den Einsatz von Saugbaggern und GPS-gestützter Vermessung können wir Schäden an Bestandsleitungen nahezu ausschließen.
|
||||||
|
|
||||||
|
## Fazit
|
||||||
|
|
||||||
|
Die Kombination aus bewährter Handwerkskunst und modernster Technik ist der Schlüssel zu erfolgreichen Infrastrukturprojekten. Wir bei E-TIB setzen konsequent auf Innovation, um unseren Kunden die bestmöglichen Ergebnisse zu liefern.
|
||||||
@@ -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="/_next/image?url=%2Fassets%2Fphotos%2FEtib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg&w=1920&q=75" 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">
|
||||||
@@ -118,7 +118,7 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
title: "Kabelleitungsnetzbau",
|
title: "Kabelleitungsnetzbau",
|
||||||
description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
|
description: "Kabelleitungsnetzbau (Nieder-/Mittel- und Hochspannung bis 110 kV)",
|
||||||
tag: "Energie",
|
tag: "Energie",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/de/kabeltiefbau",
|
href: "/de/kabeltiefbau",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ layout: "fullBleed"
|
|||||||
<HeroSection
|
<HeroSection
|
||||||
badge="Kernkompetenz"
|
badge="Kernkompetenz"
|
||||||
title="Kabelleitungsnetzbau"
|
title="Kabelleitungsnetzbau"
|
||||||
subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"
|
subtitle="Komplette Infrastruktur-Lösungen für Nieder-, Mittel- und Hochspannungsnetze bis 110 kV"
|
||||||
backgroundImage={{ url: '/assets/photos/DSC01123.JPG' }}
|
backgroundImage={{ url: '/assets/photos/DSC01123.JPG' }}
|
||||||
alignment="center"
|
alignment="center"
|
||||||
ctaLabel="Projekt anfragen"
|
ctaLabel="Projekt anfragen"
|
||||||
|
|||||||
30
content/en/blog/modern-methods-civil-engineering.mdx
Normal file
30
content/en/blog/modern-methods-civil-engineering.mdx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: "Modern Methods in Cable Civil Engineering"
|
||||||
|
date: "2024-03-15T10:00:00.000Z"
|
||||||
|
excerpt: "Learn more about the latest technologies and methods in modern cable civil engineering, from horizontal directional drilling to trenchless installation techniques."
|
||||||
|
featuredImage: "/assets/images/blog/tiefbau.jpg"
|
||||||
|
category: "Technology"
|
||||||
|
public: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Modern Methods in Cable Civil Engineering
|
||||||
|
|
||||||
|
Cable civil engineering has evolved rapidly in recent years. New technologies enable more efficient and environmentally friendly installation of utility lines.
|
||||||
|
|
||||||
|
## Trenchless Installation Techniques
|
||||||
|
|
||||||
|
One of the most important methods is **Horizontal Directional Drilling (HDD)**. This method allows pipes to be laid under obstacles such as roads, rivers, or buildings without having to break open the surface.
|
||||||
|
|
||||||
|
### Benefits of HDD Drilling:
|
||||||
|
- Minimal impact on traffic
|
||||||
|
- Protection of the environment and existing vegetation
|
||||||
|
- Shorter construction times
|
||||||
|
- Lower costs for complex crossings
|
||||||
|
|
||||||
|
## Classic Civil Engineering in New Quality
|
||||||
|
|
||||||
|
Classic trench construction also benefits from modern machinery and precise planning. By using suction excavators and GPS-supported surveying, we can virtually eliminate damage to existing utility lines.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The combination of proven craftsmanship and state-of-the-art technology is the key to successful infrastructure projects. At E-TIB, we consistently focus on innovation to deliver the best possible results for our customers.
|
||||||
@@ -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="/_next/image?url=%2Fassets%2Fphotos%2FEtib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg&w=1920&q=75" 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">
|
||||||
|
|||||||
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
6904
lh-report-prod-2.json
Normal file
6904
lh-report-prod-2.json
Normal file
File diff suppressed because one or more lines are too long
6977
lh-report-prod-en-v10.json
Normal file
6977
lh-report-prod-en-v10.json
Normal file
File diff suppressed because one or more lines are too long
6977
lh-report-prod-en-v2.json
Normal file
6977
lh-report-prod-en-v2.json
Normal file
File diff suppressed because one or more lines are too long
6973
lh-report-prod-en-v3.json
Normal file
6973
lh-report-prod-en-v3.json
Normal file
File diff suppressed because one or more lines are too long
6985
lh-report-prod-en-v4.json
Normal file
6985
lh-report-prod-en-v4.json
Normal file
File diff suppressed because one or more lines are too long
6993
lh-report-prod-en-v5.json
Normal file
6993
lh-report-prod-en-v5.json
Normal file
File diff suppressed because one or more lines are too long
6924
lh-report-prod-en-v7.json
Normal file
6924
lh-report-prod-en-v7.json
Normal file
File diff suppressed because one or more lines are too long
6950
lh-report-prod-en-v8.json
Normal file
6950
lh-report-prod-en-v8.json
Normal file
File diff suppressed because one or more lines are too long
6700
lh-report-prod-en-v9.json
Normal file
6700
lh-report-prod-en-v9.json
Normal file
File diff suppressed because one or more lines are too long
7002
lh-report-prod-en.json
Normal file
7002
lh-report-prod-en.json
Normal file
File diff suppressed because one or more lines are too long
12388
lh-report-prod.json
Normal file
12388
lh-report-prod.json
Normal file
File diff suppressed because one or more lines are too long
11166
lh-report.json
11166
lh-report.json
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user