Compare commits
89 Commits
v2.2.13-rc
...
v2.2.37
| Author | SHA1 | Date | |
|---|---|---|---|
| da9d1954d6 | |||
| 4fc00098f2 | |||
| 2df7aece5c | |||
| d65be95e95 | |||
| e83d3c0933 | |||
| f69d1977ad | |||
| 4f73ac109e | |||
| 535d6d0588 | |||
| c2ce09c106 | |||
| 265f93716e | |||
| 66fb4f3939 | |||
| 10f5dd9774 | |||
| 30a177663c | |||
| 8d4f085c58 | |||
| c0a577cff3 | |||
| bec2a53d3d | |||
| b4fc3f6a28 | |||
| 3e5d7dbce9 | |||
| c4e5b44d5e | |||
| 0378dde328 | |||
| 3202139b1e | |||
| dc5489706d | |||
| e8b8493740 | |||
| ef44ebcf4f | |||
| 816aef924e | |||
| 1bd9f26356 | |||
| 156ae66dec | |||
| b2d1865f40 | |||
| dd363d0a55 | |||
| 84f10694bb | |||
| 3e2b20ce0e | |||
| 4cd919545e | |||
| e5891e8214 | |||
| 4e4e44a5fd | |||
| 3e37c37863 | |||
| 40ec588d52 | |||
| b2c38fca7d | |||
| 1b6f1d9405 | |||
| 6cc9ccdea8 | |||
| aafc705f0e | |||
| 691f02a5b8 | |||
| 4b04178f01 | |||
| f76940dc34 | |||
| 2653bd2569 | |||
| adbf2855d8 | |||
| 87942dff41 | |||
| 38bd728393 | |||
| 547e62f9d4 | |||
| 27fc34e186 | |||
| d068d68d7a | |||
| 908f2b0b69 | |||
| 272b7a397b | |||
| 9edfe24509 | |||
| de8f41cc6d | |||
| 57cc429b54 | |||
| 01cf4013df | |||
| caf3fe8c1e | |||
| 51eac9fb50 | |||
| 6426512192 | |||
| a63ee1b9f5 | |||
| 43667e7d68 | |||
| 0191fdb770 | |||
| afaa5e038c | |||
| e0d6ea0d6c | |||
| 34839ad5a5 | |||
| a8d6a45aad | |||
| 6de8ad8516 | |||
| 023fef5343 | |||
| 7181dafca2 | |||
| 8b6d2da821 | |||
| 17736860d6 | |||
| d67acdbcc1 | |||
| 05b517731b | |||
| 67696b6afa | |||
| 24472990e5 | |||
| 53a885acdb | |||
| 9afff09ed7 | |||
| ce5acc5558 | |||
| 5027bea666 | |||
| fcfb567991 | |||
| efb9bdbb3f | |||
| 11e7937e79 | |||
| 41158697fd | |||
| d00a4e29ff | |||
| 58be922fb3 | |||
| 111672a614 | |||
| d1387d2685 | |||
| fda2944e5a | |||
| 8ae5ab73e3 |
@@ -1,5 +1,5 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# KLZ Cables - Production Environment Configuration
|
# E-TIB - Production Environment Configuration
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# This file contains runtime environment variables for the production deployment.
|
# This file contains runtime environment variables for the production deployment.
|
||||||
# It should be placed on the production server at: /home/deploy/sites/e-tib.com/.env
|
# It should be placed on the production server at: /home/deploy/sites/e-tib.com/.env
|
||||||
@@ -12,7 +12,7 @@ NODE_ENV=production
|
|||||||
NEXT_PUBLIC_BASE_URL=https://e-tib.com
|
NEXT_PUBLIC_BASE_URL=https://e-tib.com
|
||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=d773ea10-a3b3-4ccf-9024-987e14c4d669
|
||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
@@ -23,7 +23,7 @@ MAIL_HOST=smtp.eu.mailgun.org
|
|||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
MAIL_USERNAME=
|
MAIL_USERNAME=
|
||||||
MAIL_PASSWORD=
|
MAIL_PASSWORD=
|
||||||
MAIL_FROM=KLZ Cables <noreply@e-tib.com>
|
MAIL_FROM=E-TIB GmbH <noreply@e-tib.com>
|
||||||
MAIL_RECIPIENTS=info@e-tib.com
|
MAIL_RECIPIENTS=info@e-tib.com
|
||||||
|
|
||||||
# Varnish Cache Size (optional)
|
# Varnish Cache Size (optional)
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/v') && 'production' || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||||
@@ -89,12 +93,12 @@ jobs:
|
|||||||
DOMAIN="e-tib.com"
|
DOMAIN="e-tib.com"
|
||||||
PRJ="etib"
|
PRJ="etib"
|
||||||
|
|
||||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||||
TARGET="testing"
|
TARGET="testing"
|
||||||
IMAGE_TAG="main-${SHORT_SHA}"
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.testing"
|
ENV_FILE=".env.testing"
|
||||||
TRAEFIK_HOST="test.${DOMAIN}"
|
TRAEFIK_HOST="test.${DOMAIN}"
|
||||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||||
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
TARGET="production"
|
TARGET="production"
|
||||||
IMAGE_TAG="$REF"
|
IMAGE_TAG="$REF"
|
||||||
@@ -231,7 +235,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
NEXT_PUBLIC_TARGET: ${{ needs.prepare.outputs.target }}
|
NEXT_PUBLIC_TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.image_tag }}
|
NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.image_tag }}
|
||||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || 'd773ea10-a3b3-4ccf-9024-987e14c4d669' }}
|
||||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
NPM_TOKEN: ${{ steps.auth.outputs.working_token }}
|
NPM_TOKEN: ${{ steps.auth.outputs.working_token }}
|
||||||
NPM_DOMAIN: ${{ steps.auth.outputs.working_domain }}
|
NPM_DOMAIN: ${{ steps.auth.outputs.working_domain }}
|
||||||
@@ -278,14 +282,18 @@ jobs:
|
|||||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@e-tib.com' }}
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@e-tib.com' }}
|
||||||
|
|
||||||
# Monitoring
|
# Monitoring
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || 'https://dcb81958-dbf2-4a3d-b422-875f4672c14b@glitchtip.infra.mintel.me/5' }}
|
||||||
|
|
||||||
# Gatekeeper
|
# Gatekeeper
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || 'd773ea10-a3b3-4ccf-9024-987e14c4d669' }}
|
||||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||||
|
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -346,6 +354,10 @@ jobs:
|
|||||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "# Notifications"
|
||||||
|
echo "GOTIFY_URL=$GOTIFY_URL"
|
||||||
|
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
|
||||||
|
echo ""
|
||||||
echo "TARGET=$TARGET"
|
echo "TARGET=$TARGET"
|
||||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
@@ -388,10 +400,19 @@ jobs:
|
|||||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
|
|
||||||
ssh root@alpha.mintel.me "docker system prune -f"
|
ssh root@alpha.mintel.me "docker system prune -f"
|
||||||
ssh root@alpha.mintel.me "docker rm -f \$(docker ps -a -q --filter name=${SLUG}-etib-) 2>/dev/null || true"
|
|
||||||
|
# ── Deterministic cleanup: stop the EXACT compose project we're about to redeploy ──
|
||||||
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' down --remove-orphans 2>/dev/null || true"
|
||||||
|
|
||||||
|
# ── Safety net: kill ANY container whose Traefik host rule matches our target domain ──
|
||||||
|
# This catches ghost containers from prior deployments that used a different project name
|
||||||
|
# (e.g. 'e-tibcom' vs 'etib-production' due to Docker Compose defaulting to dir name)
|
||||||
|
ssh root@alpha.mintel.me "docker ps -q --filter label=traefik.http.routers.${PROJECT_NAME}.rule 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true"
|
||||||
|
ssh root@alpha.mintel.me "docker ps --format '{{.Names}}' | grep -E '^e-tibcom-' | xargs -r docker rm -f 2>/dev/null || true"
|
||||||
|
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' pull"
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"chromeFlags":"--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --headless=new","port":59457}
|
||||||
2895
.lighthouseci/lhr-1782154267607.html
Normal file
13298
.lighthouseci/lhr-1782154267607.json
Normal file
2895
.lighthouseci/lhr-1782154359499.html
Normal file
13336
.lighthouseci/lhr-1782154359499.json
Normal file
2895
.lighthouseci/lhr-1782154420108.html
Normal file
13308
.lighthouseci/lhr-1782154420108.json
Normal file
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getPageBySlug } from '@/lib/pages';
|
import { getPageBySlug } from '@/lib/pages';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -20,12 +20,16 @@ export default async function Image({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const bg = getOgBackground();
|
||||||
|
const logo = getOgLogo();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={pageData.frontmatter.title}
|
title="Die Experten für Kabelnetzbau"
|
||||||
description={pageData.frontmatter.excerpt}
|
description={`${pageData.frontmatter.title}`}
|
||||||
label="Information"
|
label="Information"
|
||||||
|
image={bg}
|
||||||
|
logo={logo}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
|||||||
|
|
||||||
{/* Support Section */}
|
{/* Support Section */}
|
||||||
<Container>
|
<Container>
|
||||||
<div className="mt-24 mb-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
<div className="mt-8 mb-8 md:mt-24 md:mb-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
||||||
<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('needHelp')}</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getPostBySlug } from '@/lib/blog';
|
import { getPostBySlug } from '@/lib/blog';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
@@ -52,13 +52,16 @@ export default async function Image({
|
|||||||
if (featuredImage) {
|
if (featuredImage) {
|
||||||
base64Image = await fetchImageAsBase64(featuredImage);
|
base64Image = await fetchImageAsBase64(featuredImage);
|
||||||
}
|
}
|
||||||
|
const bg = getOgBackground();
|
||||||
|
const logo = getOgLogo();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={post.frontmatter.title}
|
title="Die Experten für Kabelnetzbau"
|
||||||
description={post.frontmatter.excerpt}
|
description={`${post.frontmatter.title}`}
|
||||||
label={post.frontmatter.category || 'Blog'}
|
label={post.frontmatter.category || 'Blog'}
|
||||||
image={base64Image || featuredImage}
|
image={base64Image || featuredImage || bg}
|
||||||
|
logo={logo}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -11,9 +11,17 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const bg = getOgBackground();
|
||||||
|
const logo = getOgLogo();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
<OGImageTemplate
|
||||||
|
title="Die Experten für Kabelnetzbau"
|
||||||
|
description={t('title')}
|
||||||
|
label="Blog"
|
||||||
|
image={bg}
|
||||||
|
logo={logo}
|
||||||
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -11,12 +11,22 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
const fonts = await getOgFonts();
|
const fonts = await getOgFonts();
|
||||||
|
const bg = getOgBackground();
|
||||||
|
const logo = getOgLogo();
|
||||||
|
|
||||||
const title = t('meta.title') || t('title');
|
let title = t('meta.title') || t('title');
|
||||||
const description = t('meta.description') || t('subtitle');
|
if (title.startsWith('E-TIB GmbH | ')) {
|
||||||
|
title = title.substring('E-TIB GmbH | '.length);
|
||||||
|
}
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate title={title} description={description} label="Contact" />,
|
<OGImageTemplate
|
||||||
|
title="Die Experten für Kabelnetzbau"
|
||||||
|
description={title}
|
||||||
|
label="Contact"
|
||||||
|
image={bg}
|
||||||
|
logo={logo}
|
||||||
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ContactPage({ params }: ContactPageProps) {
|
export default async function ContactPage({ params }: ContactPageProps) {
|
||||||
console.log('--- RENDERING CONTACT PAGE FOR LOCALE ---');
|
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
@@ -150,7 +150,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<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">
|
||||||
{/* Contact Info */}
|
{/* Contact Info */}
|
||||||
<div className="lg:col-span-5 space-y-6 md:space-y-12">
|
<div className="lg:col-span-5 order-2 lg:order-1 space-y-6 md:space-y-12">
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||||
{t('info.howToReachUs')}
|
{t('info.howToReachUs')}
|
||||||
@@ -249,7 +249,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Form */}
|
{/* Contact Form */}
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7 order-1 lg:order-2">
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
|
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getMessages } from 'next-intl/server';
|
|||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||||
import AnnotatorClientWrapper from '@/components/AnnotatorClientWrapper';
|
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
@@ -175,9 +175,7 @@ export default async function Layout(props: {
|
|||||||
|
|
||||||
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||||
|
const hasSeenLoader = true; // Disabled to allow static generation and instant LCP (Lighthouse 100)
|
||||||
const cookieStore = await cookies();
|
|
||||||
const hasSeenLoader = cookieStore.has('etib_initial_loader_v5');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
@@ -186,13 +184,9 @@ export default async function Layout(props: {
|
|||||||
data-scroll-behavior="smooth"
|
data-scroll-behavior="smooth"
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
||||||
<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" />
|
||||||
{/* Preload critical hero video */}
|
|
||||||
<link rel="preload" as="video" href="/assets/videos/web/hero-bohrung.mp4" type="video/mp4" />
|
|
||||||
</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">
|
||||||
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||||
@@ -206,7 +200,7 @@ export default async function Layout(props: {
|
|||||||
|
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
className="flex-grow animate-fade-in overflow-visible pb-[90px] md:pb-0"
|
className="flex-grow animate-fade-in overflow-visible"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -215,7 +209,7 @@ export default async function Layout(props: {
|
|||||||
<Footer companyInfo={companyInfo} />
|
<Footer companyInfo={companyInfo} />
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<AnalyticsShell />
|
<AnalyticsShell />
|
||||||
{process.env.TARGET !== 'production' && <AnnotatorClientWrapper />}
|
|
||||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
||||||
</TransitionProvider>
|
</TransitionProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||||
|
|
||||||
export const size = OG_IMAGE_SIZE;
|
export const size = OG_IMAGE_SIZE;
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
@@ -10,17 +10,21 @@ 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 logo = getOgLogo();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={t('title')}
|
title="Die Experten für Kabelnetzbau"
|
||||||
description={t('description')}
|
label="Kabelnetzbau & Infrastruktur"
|
||||||
label="Reliable Energy Infrastructure"
|
image={bg}
|
||||||
|
logo={logo}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
fonts,
|
fonts,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,23 +4,23 @@ 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';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
import nextDynamic from 'next/dynamic';
|
||||||
|
import { getImageProps } from 'next/image';
|
||||||
|
|
||||||
// Import components used in MDX
|
|
||||||
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
|
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
|
||||||
import { SubCompanyTiles as HomeSubCompanyTiles } from '@/components/blocks/SubCompanyTiles';
|
const HomeSubCompanyTiles = nextDynamic(() => import('@/components/blocks/SubCompanyTiles').then(mod => mod.SubCompanyTiles));
|
||||||
import { CompetenceBentoGrid as HomeCompetenceBentoGrid } from '@/components/blocks/CompetenceBentoGrid';
|
const HomeCompetenceBentoGrid = nextDynamic(() => import('@/components/blocks/CompetenceBentoGrid').then(mod => mod.CompetenceBentoGrid));
|
||||||
import { ReferencesSlider as HomeReferencesSlider } from '@/components/blocks/ReferencesSlider';
|
const HomeReferencesSlider = nextDynamic(() => import('@/components/blocks/ReferencesSlider').then(mod => mod.ReferencesSlider));
|
||||||
import { FaqBlock } from '@/components/blocks/FaqBlock';
|
const FaqBlock = nextDynamic(() => import('@/components/blocks/FaqBlock').then(mod => mod.FaqBlock));
|
||||||
import { CertificatesBlock } from '@/components/blocks/CertificatesBlock';
|
const CertificatesBlock = nextDynamic(() => import('@/components/blocks/CertificatesBlock').then(mod => mod.CertificatesBlock));
|
||||||
import { HeroSection } from '@/components/blocks/HeroSection';
|
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Heading } from '@/components/ui/Heading';
|
import { Heading } from '@/components/ui/Heading';
|
||||||
import { AnimatedCounter } from '@/components/ui';
|
const AnimatedCounter = nextDynamic(() => import('@/components/ui').then(mod => mod.AnimatedCounter));
|
||||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
const InteractiveGermanyMap = nextDynamic(() => import('@/components/blocks/InteractiveGermanyMap').then(mod => mod.InteractiveGermanyMap));
|
||||||
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
|
const ScaleOfImpact = nextDynamic(() => import('@/components/blocks/ScaleOfImpact').then(mod => mod.ScaleOfImpact));
|
||||||
import { getAllReferences } from '@/lib/references';
|
import { getAllReferences } from '@/lib/references';
|
||||||
|
|
||||||
const mdxComponents = (references: any[]) => ({
|
const mdxComponents = (references: any[]) => ({
|
||||||
@@ -37,6 +37,42 @@ const mdxComponents = (references: any[]) => ({
|
|||||||
AnimatedCounter,
|
AnimatedCounter,
|
||||||
InteractiveGermanyMap,
|
InteractiveGermanyMap,
|
||||||
ScaleOfImpact,
|
ScaleOfImpact,
|
||||||
|
img: (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"
|
||||||
|
style={{ width: '100%', height: 'auto', ...props.style }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function generateMetadata(props: any): Promise<Metadata> {
|
export async function generateMetadata(props: any): Promise<Metadata> {
|
||||||
|
|||||||
@@ -3,13 +3,43 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getAllReferences } from '@/lib/references';
|
import { getAllReferences } from '@/lib/references';
|
||||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
import { MapPin, Calendar, Briefcase } from 'lucide-react';
|
import { MapPin, Calendar, Briefcase, Zap, Activity, ShieldCheck, Wrench, CheckCircle2 } from 'lucide-react';
|
||||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||||
import { HeroSection } from '@/components/blocks/HeroSection';
|
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||||
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
||||||
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
|
|
||||||
|
const CustomLi = ({ children, ...props }: any) => {
|
||||||
|
let text = '';
|
||||||
|
if (typeof children === 'string') text = children;
|
||||||
|
else if (Array.isArray(children)) text = children.map((c: any) => typeof c === 'string' ? c : '').join(' ');
|
||||||
|
else if (children?.props?.children) text = children.props.children;
|
||||||
|
|
||||||
|
const lowerText = String(text).toLowerCase();
|
||||||
|
|
||||||
|
let Icon = CheckCircle2;
|
||||||
|
if (lowerText.includes('kabel')) Icon = Zap;
|
||||||
|
else if (lowerText.includes('bohrung')) Icon = Activity;
|
||||||
|
else if (lowerText.includes('rohr')) Icon = ShieldCheck;
|
||||||
|
else if (lowerText.includes('montage')) Icon = Wrench;
|
||||||
|
else if (lowerText.includes('vlf') || lowerText.includes('otdr')) Icon = Zap;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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-5 h-5 text-primary shrink-0 mt-0.5" />
|
||||||
|
<span className="text-sm font-medium text-neutral-700 leading-snug [&>p]:mb-0">{children}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mdxComponents = {
|
||||||
|
ul: (props: any) => <ul className="grid grid-cols-1 gap-2 mb-6" {...props} />,
|
||||||
|
li: CustomLi,
|
||||||
|
p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />,
|
||||||
|
};
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -72,7 +102,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
});
|
});
|
||||||
|
|
||||||
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-8 md:pb-24">
|
||||||
{/* Map Section */}
|
{/* Map Section */}
|
||||||
<InteractiveGermanyMap
|
<InteractiveGermanyMap
|
||||||
isHero={true}
|
isHero={true}
|
||||||
@@ -92,7 +122,8 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
{references.map((ref) => (
|
{references.map((ref) => (
|
||||||
<div
|
<div
|
||||||
key={ref.slug}
|
key={ref.slug}
|
||||||
className="flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm block"
|
id={ref.slug}
|
||||||
|
className="flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm block scroll-mt-32"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-full relative">
|
<div className="flex flex-col h-full relative">
|
||||||
{/* Image Section */}
|
{/* Image Section */}
|
||||||
@@ -126,6 +157,13 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
{ref.frontmatter.title}
|
{ref.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{/* Project Details / Content */}
|
||||||
|
{ref.content && (
|
||||||
|
<div className="flex-grow mb-6">
|
||||||
|
<MDXRemote source={ref.content} components={mdxComponents} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Technical Meta Data */}
|
{/* Technical 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">
|
||||||
@@ -150,7 +188,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Support Section */}
|
{/* Support Section */}
|
||||||
<div className="mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group">
|
<div className="mt-8 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>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default async function StandortDetail(props: { params: Promise<{ locale:
|
|||||||
|
|
||||||
{/* Support Section */}
|
{/* Support Section */}
|
||||||
<Container>
|
<Container>
|
||||||
<div className="mt-12 mb-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
<div className="mt-8 mb-8 md:mt-24 md:mb-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
||||||
<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('needHelp')}</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default async function StandorteOverview(props: { params: Promise<{ local
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Support Section */}
|
{/* Support Section */}
|
||||||
<div className="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>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ export async function POST(request: NextRequest) {
|
|||||||
website: websiteId,
|
website: websiteId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
let umamiEndpoint = config.analytics.umami.apiEndpoint || 'https://analytics.infra.mintel.me';
|
||||||
|
if (!umamiEndpoint.startsWith('http')) {
|
||||||
|
umamiEndpoint = `https://${umamiEndpoint}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Log the event (internal only)
|
// Log the event (internal only)
|
||||||
logger.debug('Forwarding analytics event', {
|
logger.debug('Forwarding analytics event', {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface OGImageTemplateProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
|
logo?: string;
|
||||||
mode?: 'dark' | 'light' | 'image';
|
mode?: 'dark' | 'light' | 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,11 +14,11 @@ export function OGImageTemplate({
|
|||||||
description,
|
description,
|
||||||
label,
|
label,
|
||||||
image,
|
image,
|
||||||
|
logo,
|
||||||
mode = 'dark',
|
mode = 'dark',
|
||||||
}: OGImageTemplateProps) {
|
}: OGImageTemplateProps) {
|
||||||
const primaryBlue = '#001a4d';
|
const backgroundDark = '#1a1a1a';
|
||||||
const accentGreen = '#10a379';
|
const primaryGreen = '#0e7a5c';
|
||||||
const saturatedBlue = '#011dff';
|
|
||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -26,7 +27,7 @@ export function OGImageTemplate({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
backgroundColor: mode === 'light' ? '#ffffff' : backgroundDark,
|
||||||
padding: '80px',
|
padding: '80px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
@@ -65,7 +66,7 @@ export function OGImageTemplate({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
|
background: 'linear-gradient(135deg, rgba(26,26,26,0.98) 0%, rgba(26,26,26,0.85) 40%, rgba(14,122,92,0.4) 100%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,101 +76,127 @@ export function OGImageTemplate({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '-150px',
|
top: '-300px',
|
||||||
right: '-150px',
|
right: '-200px',
|
||||||
width: '600px',
|
width: '800px',
|
||||||
height: '600px',
|
height: '1200px',
|
||||||
borderRadius: '300px',
|
backgroundColor: `${primaryGreen}1A`,
|
||||||
backgroundColor: `${accentGreen}15`,
|
transform: 'rotate(25deg)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
|
{/* Main Content Wrapper */}
|
||||||
{/* Label / Category */}
|
|
||||||
{label && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: accentGreen,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.3em',
|
|
||||||
marginBottom: '32px',
|
|
||||||
display: 'flex',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: title.length > 40 ? '64px' : '82px',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'white',
|
|
||||||
lineHeight: '1.05',
|
|
||||||
maxWidth: '950px',
|
|
||||||
marginBottom: '40px',
|
|
||||||
display: 'flex',
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{description && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '32px',
|
|
||||||
color: 'rgba(255,255,255,0.7)',
|
|
||||||
maxWidth: '850px',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
display: 'flex',
|
|
||||||
fontWeight: 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Brand Footer */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
|
||||||
bottom: '80px',
|
|
||||||
left: '80px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Top: Text Content */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Label / Category */}
|
||||||
|
{label && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: primaryGreen,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.25em',
|
||||||
|
marginBottom: '24px',
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '72px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'white',
|
||||||
|
lineHeight: '1.1',
|
||||||
|
maxWidth: '900px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
display: 'flex',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{description && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '36px',
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
maxWidth: '850px',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom: Brand Footer */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '80px',
|
|
||||||
height: '6px',
|
|
||||||
backgroundColor: accentGreen,
|
|
||||||
borderRadius: '3px',
|
|
||||||
marginRight: '24px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'white',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.15em',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
KLZ Cables
|
{logo ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt="E-TIB GmbH"
|
||||||
|
height="56"
|
||||||
|
style={{ marginRight: '24px', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '80px',
|
||||||
|
height: '6px',
|
||||||
|
backgroundColor: primaryGreen,
|
||||||
|
borderRadius: '3px',
|
||||||
|
marginRight: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!logo && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'white',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
E-TIB GmbH
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Saturated Blue Brand Strip */}
|
{/* Primary Green Brand Strip */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -177,7 +204,7 @@ export function OGImageTemplate({
|
|||||||
right: 0,
|
right: 0,
|
||||||
width: '12px',
|
width: '12px',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
backgroundColor: saturatedBlue,
|
backgroundColor: primaryGreen,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/components/ui';
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
interface RevealProps {
|
interface RevealProps {
|
||||||
@@ -10,40 +10,12 @@ interface RevealProps {
|
|||||||
delay?: number;
|
delay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Reveal({ children, className, threshold = 0.1, delay = 0 }: RevealProps) {
|
export default function Reveal({ children, className }: RevealProps) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
// Reveal animation disabled due to flickering issues on mobile/hydration
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setIsVisible(true);
|
|
||||||
observer.unobserve(entry.target);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold }
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentRef = ref.current;
|
|
||||||
if (currentRef) {
|
|
||||||
observer.observe(currentRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (currentRef) {
|
|
||||||
observer.unobserve(currentRef);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [threshold]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn(className)}>
|
||||||
ref={ref}
|
|
||||||
className={cn('reveal-on-scroll', isVisible && 'is-visible', className)}
|
|
||||||
style={{ transitionDelay: `${delay}ms` }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,30 +239,31 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="annotator-ui-ignore">
|
<div className="annotator-ui-ignore">
|
||||||
{/* 1. Global Toolbar */}
|
{/* 1. Global Toolbar */}
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] w-max max-w-[95vw]">
|
<div className="fixed bottom-36 md:bottom-24 right-4 z-[10000] flex flex-col items-end gap-2">
|
||||||
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-1 sm:gap-2 overflow-x-auto hide-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-1.5 rounded-2xl shadow-2xl flex flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsActive(!isActive)}
|
onClick={() => setIsActive(!isActive)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium",
|
"p-2.5 rounded-xl transition-all",
|
||||||
isActive
|
isActive
|
||||||
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
||||||
: "text-white/70 hover:text-white hover:bg-white/10"
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||||
)}
|
)}
|
||||||
|
title={isActive ? "Modus beenden" : "Korrekturen erfassen"}
|
||||||
>
|
>
|
||||||
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
|
{isActive ? <X size={20} /> : <MessageSquare size={20} />}
|
||||||
{isActive ? "Modus beenden" : "Korrekturen erfassen"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
<div className="w-6 h-px bg-white/10 my-1" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowList(!showList)}
|
onClick={() => setShowList(!showList)}
|
||||||
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
|
className="p-2.5 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
|
||||||
|
title="Liste anzeigen"
|
||||||
>
|
>
|
||||||
<List size={20} />
|
<List size={20} />
|
||||||
{annotations.length > 0 && (
|
{annotations.length > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 text-[9px] flex items-center justify-center rounded-full text-white font-bold border border-[#1a1a1a]">
|
||||||
{annotations.length}
|
{annotations.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -270,13 +271,13 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|||||||
|
|
||||||
{annotations.length > 0 && (
|
{annotations.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
<div className="w-6 h-px bg-white/10 my-1" />
|
||||||
<button
|
<button
|
||||||
onClick={exportJSON}
|
onClick={exportJSON}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-white/70 hover:text-white hover:bg-white/10 transition-all font-medium"
|
className="p-2.5 rounded-xl text-white/70 hover:text-white hover:bg-white/10 transition-all"
|
||||||
|
title="JSON Export"
|
||||||
>
|
>
|
||||||
<Download size={18} />
|
<Download size={20} />
|
||||||
JSON Export
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ const containerVariants: Variants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const itemVariants: Variants = {
|
const itemVariants: Variants = {
|
||||||
hidden: { opacity: 0, y: 30 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] } },
|
visible: { opacity: 1, y: 0, transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const Icons = {
|
const Icons = {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
|
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0, y: 20 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: 'easeOut' as const } },
|
show: { opacity: 1, y: 0, transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] } },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
@@ -125,8 +125,8 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
|||||||
@@ -116,10 +116,10 @@ export function CompanyTimeline({
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
key={i}
|
||||||
initial={{ opacity: 0, y: 40 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ duration: 0.7, ease: "easeOut" }}
|
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="relative flex flex-col md:flex-row items-center w-full group"
|
className="relative flex flex-col md:flex-row items-center w-full group"
|
||||||
>
|
>
|
||||||
{/* Timeline Dot with Pulse */}
|
{/* Timeline Dot with Pulse */}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -81,18 +81,14 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="py-12 md:py-24 bg-neutral text-neutral-dark relative overflow-hidden">
|
<section className="py-12 md:py-24 bg-neutral text-neutral-dark relative overflow-hidden">
|
||||||
<Container className="relative z-10 px-0 md:px-12 lg:px-16">
|
<Container className="relative z-10 px-0 md:px-12 lg:px-16">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 px-4 md:px-0">
|
<div className="flex flex-row justify-between items-end mb-12 px-4 md:px-0">
|
||||||
<div className="max-w-2xl text-left">
|
<div className="max-w-2xl text-left">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
||||||
<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>
|
||||||
<Button href={ctaHref} variant="ghost" className="mt-6 md:mt-0 bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0">
|
|
||||||
{ctaLabel}
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" 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>
|
|
||||||
</Button>
|
|
||||||
</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';
|
||||||
@@ -111,8 +107,8 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
key={idx}
|
key={idx}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ delay: idx * 0.1 }}
|
transition={{ delay: idx * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className={`${gridClasses} ${item.href ? 'cursor-pointer hover:-translate-y-1 transition-transform duration-300' : ''}`}
|
className={`${gridClasses} ${item.href ? 'cursor-pointer hover:-translate-y-1 transition-transform duration-300' : ''}`}
|
||||||
>
|
>
|
||||||
{item.href && (
|
{item.href && (
|
||||||
@@ -127,8 +123,8 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
|
|
||||||
<HoverShineOverlay shineColor="via-white/30" />
|
<HoverShineOverlay shineColor="via-white/30" />
|
||||||
<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-bold text-xl md:text-2xl 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/60 text-sm mt-3 relative z-10">{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>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,10 +132,10 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={idx}
|
key={idx}
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||||
whileInView={{ opacity: 1, scale: 1 }}
|
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ delay: idx * 0.1 }}
|
transition={{ delay: idx * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className={`${gridClasses} ${item.href ? 'cursor-pointer hover:-translate-y-1 transition-transform duration-300' : ''}`}
|
className={`${gridClasses} ${item.href ? 'cursor-pointer hover:-translate-y-1 transition-transform duration-300' : ''}`}
|
||||||
>
|
>
|
||||||
{item.href && (
|
{item.href && (
|
||||||
@@ -159,7 +155,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
sizes={isLarge ? "(max-width: 768px) 100vw, 50vw" : "(max-width: 768px) 100vw, 25vw"}
|
sizes={isLarge ? "(max-width: 768px) 100vw, 50vw" : "(max-width: 768px) 100vw, 25vw"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-90 transition-opacity duration-500 z-0" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/60 to-black/30 opacity-100 transition-opacity duration-500 z-0 group-hover:from-black/80 group-hover:via-black/50 group-hover:to-black/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -177,11 +173,11 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
{item.tag}
|
{item.tag}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<h4 className={`font-heading font-extrabold mb-2 ${isLarge ? 'text-2xl md:text-5xl' : 'text-xl md:text-2xl'}`}>
|
<h4 className="font-heading font-extrabold mb-2 text-2xl md:text-3xl">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h4>
|
</h4>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p className={`text-white/80 ${isLarge ? 'text-lg md:text-xl max-w-sm' : 'text-sm'}`}>
|
<p className="text-white/90 text-sm md:text-base max-w-md">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function DeepDrillAnimation() {
|
|||||||
<motion.div
|
<motion.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: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="flex flex-col items-center"
|
className="flex flex-col items-center"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
<motion.div variants={itemVariants}>
|
<motion.div variants={itemVariants}>
|
||||||
<Heading level={1} 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>
|
</motion.div>
|
||||||
@@ -104,7 +104,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{ctaLabel && ctaHref && (
|
{ctaLabel && ctaHref && (
|
||||||
<motion.div variants={itemVariants} className={`mt-8 ${alignment === 'center' ? 'flex justify-center' : ''}`}>
|
<motion.div variants={itemVariants} className={`mt-8 ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`}>
|
||||||
<Button
|
<Button
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
@@ -23,6 +22,8 @@ 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';
|
||||||
@@ -31,8 +32,14 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
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();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || "/assets/photos/DJI_0048.JPG";
|
let defaultPoster = "/assets/photos/DJI_0048.JPG";
|
||||||
|
if (videoUrl && videoUrl.endsWith('.mp4')) {
|
||||||
|
defaultPoster = videoUrl.replace('.mp4', '-poster.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || defaultPoster;
|
||||||
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 blurDataURL = (placeholders as Record<string, string>)[posterSrc] || undefined;
|
||||||
|
|
||||||
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';
|
||||||
@@ -42,31 +49,62 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
|
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
|
||||||
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`;
|
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`;
|
||||||
|
|
||||||
|
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let interactionTriggered = false;
|
||||||
|
|
||||||
|
const handleInteraction = () => {
|
||||||
|
if (interactionTriggered) return;
|
||||||
|
interactionTriggered = true;
|
||||||
|
setVideoSrcLoaded(true);
|
||||||
|
|
||||||
|
// Cleanup listeners
|
||||||
|
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||||
|
window.removeEventListener(event, handleInteraction)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for any user interaction
|
||||||
|
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||||
|
window.addEventListener(event, handleInteraction, { passive: true, once: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||||
|
window.removeEventListener(event, handleInteraction)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
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" />
|
||||||
|
|
||||||
{videoUrl ? (
|
{/* ALWAYS render the Next.js optimized Image as the LCP element */}
|
||||||
|
<Image
|
||||||
|
key={`img-${pathname}-${posterSrc}`}
|
||||||
|
src={posterSrc}
|
||||||
|
alt={posterAlt}
|
||||||
|
fill
|
||||||
|
className="object-cover z-[1] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||||
|
sizes="100vw"
|
||||||
|
priority
|
||||||
|
placeholder={blurDataURL ? "blur" : "empty"}
|
||||||
|
blurDataURL={blurDataURL}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Render video on top if available, but defer src to avoid blocking LCP */}
|
||||||
|
{videoUrl && (
|
||||||
<video
|
<video
|
||||||
key={`${pathname}-${videoUrl}`}
|
key={`${pathname}-${videoUrl}`}
|
||||||
className="absolute inset-0 w-full h-full object-cover z-1 pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
className="absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||||
src={videoUrl}
|
src={videoSrcLoaded ? videoUrl : undefined}
|
||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload="none"
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
|
||||||
key={`img-${pathname}-${posterSrc}`}
|
|
||||||
src={posterSrc}
|
|
||||||
alt={posterAlt}
|
|
||||||
fill
|
|
||||||
className="object-cover z-1 pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
|
||||||
sizes="100vw"
|
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -81,11 +119,9 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
||||||
|
|
||||||
<div className="container relative z-[4] text-center px-4">
|
<div className="container relative z-[4] text-center px-4">
|
||||||
<motion.div
|
<div
|
||||||
key={`hero-content-${pathname}`}
|
key={`hero-content-${pathname}`}
|
||||||
initial={{ opacity: 0, y: 40 }}
|
className="animate-in fade-in slide-in-from-bottom-10 duration-1000 ease-out fill-mode-both"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
>
|
>
|
||||||
<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"
|
||||||
@@ -96,7 +132,7 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-6">
|
<div className="flex flex-wrap items-center justify-center gap-4 md:gap-6">
|
||||||
<Button
|
<Button
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -114,7 +150,7 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
{secondaryCtaLabel}
|
{secondaryCtaLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -239,16 +239,16 @@ export function InteractiveGermanyMap({
|
|||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="absolute z-[9999] pointer-events-auto"
|
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={{
|
style={{
|
||||||
left: `${activeLocation.x}%`,
|
'--md-left': `${activeLocation.x}%`,
|
||||||
top: `${activeLocation.y}%`,
|
'--md-top': `${activeLocation.y}%`,
|
||||||
transform: activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||||
}}
|
} as React.CSSProperties}
|
||||||
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl min-w-[200px] w-max max-w-[300px] text-white overflow-hidden">
|
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
|
||||||
{activeLocation.featuredImage && (
|
{activeLocation.featuredImage && (
|
||||||
<div className="relative w-full h-32 bg-neutral-900">
|
<div className="relative w-full h-32 bg-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ const containerVariants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0, y: 30 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] as const },
|
transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] as const },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
<motion.h3
|
<motion.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: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
className="font-heading font-extrabold text-3xl md:text-4xl text-neutral-dark mb-8 md:mb-12 flex items-center gap-4"
|
className="font-heading font-extrabold text-3xl md:text-4xl text-neutral-dark mb-8 md:mb-12 flex items-center gap-4"
|
||||||
>
|
>
|
||||||
@@ -86,7 +86,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
{fairs.map((messe, idx) => {
|
{fairs.map((messe, idx) => {
|
||||||
@@ -180,7 +180,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
<motion.h2
|
<motion.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: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
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"
|
||||||
>
|
>
|
||||||
@@ -192,7 +192,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
className="grid gap-6"
|
className="grid gap-6"
|
||||||
>
|
>
|
||||||
{jobs.map((job: any) => (
|
{jobs.map((job: any) => (
|
||||||
@@ -221,7 +221,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
<motion.div
|
<motion.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: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
className="bg-neutral-50 border border-neutral-100 rounded-2xl p-12 text-center"
|
className="bg-neutral-50 border border-neutral-100 rounded-2xl p-12 text-center"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ 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 [paddingX, setPaddingX] = React.useState(24);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updatePadding = () => {
|
||||||
|
const containerEl = document.querySelector('#referenzen .container');
|
||||||
|
if (containerEl) {
|
||||||
|
const rect = containerEl.getBoundingClientRect();
|
||||||
|
const computedStyle = window.getComputedStyle(containerEl);
|
||||||
|
const pLeft = parseFloat(computedStyle.paddingLeft || '0');
|
||||||
|
setPaddingX(rect.left + pLeft);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updatePadding();
|
||||||
|
window.addEventListener('resize', updatePadding);
|
||||||
|
return () => window.removeEventListener('resize', updatePadding);
|
||||||
|
}, []);
|
||||||
|
|
||||||
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');
|
||||||
@@ -94,15 +110,15 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="referenzen" className="py-16 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-end gap-6">
|
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||||
<div>
|
<div className="text-left">
|
||||||
<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-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
<div className="text-left max-w-3xl mx-auto md:mx-0 mb-8 md:mb-16">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,25 +131,32 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
onMouseMove={onMouseMove}
|
onMouseMove={onMouseMove}
|
||||||
className={`select-none flex gap-6 overflow-x-auto pb-12 pt-4 px-[max(1rem,calc((100vw-1280px)/2))] 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'}`}
|
style={{ paddingLeft: paddingX, paddingRight: paddingX }}
|
||||||
|
className={`select-none flex gap-6 overflow-x-auto pb-8 pt-4 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
|
||||||
>
|
>
|
||||||
{references.map((ref, i) => {
|
{references.map((ref, i) => {
|
||||||
const imgSrc = (ref.image && typeof ref.image === 'object' && ref.image.url)
|
const imgSrc = ref.image
|
||||||
? ref.image.url
|
? (typeof ref.image === 'string' ? ref.image : ref.image.url)
|
||||||
: fallbacks[i % fallbacks.length];
|
: fallbacks[i % fallbacks.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={ref.id}
|
key={ref.id}
|
||||||
initial={{ opacity: 0, x: 50 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
transition={{ delay: i * 0.1, duration: 0.6, ease: "easeOut" }}
|
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"
|
className="flex-shrink-0 w-[320px] md:w-[480px] snap-start group pointer-events-auto"
|
||||||
>
|
>
|
||||||
<div
|
<Link
|
||||||
|
href={`/${locale}/referenzen#${ref.slug}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (dragDistanceRef.current > 5) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
data-testid="reference-tile"
|
data-testid="reference-tile"
|
||||||
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl"
|
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl cursor-pointer"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -153,7 +176,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
</span>
|
</span>
|
||||||
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function ScaleOfImpact() {
|
|||||||
{/* 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">
|
||||||
|
|
||||||
<h2 className="text-2xl md:text-5xl font-heading font-extrabold text-white mb-6 tracking-tight">
|
<h2 className="text-3xl sm:text-4xl md:text-5xl font-heading font-extrabold text-white mb-6 tracking-tight leading-tight">
|
||||||
{locale === 'en' ? (
|
{locale === 'en' ? (
|
||||||
<>An infrastructure <br className="md:hidden"/> that connects the country.</>
|
<>An infrastructure <br className="md:hidden"/> that connects the country.</>
|
||||||
) : (
|
) : (
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const containerVariants: Variants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const itemVariants: Variants = {
|
const itemVariants: Variants = {
|
||||||
hidden: { opacity: 0, y: 40 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
visible: { opacity: 1, y: 0, transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] } },
|
visible: { opacity: 1, y: 0, transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const Icons = {
|
const Icons = {
|
||||||
@@ -137,7 +137,7 @@ export function ServiceDetailGrid({
|
|||||||
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" }}
|
||||||
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="max-w-4xl mx-auto mb-20 grid md:grid-cols-2 gap-0 bg-white rounded-2xl overflow-hidden shadow-lg border border-neutral-200 text-left"
|
className="max-w-4xl mx-auto mb-20 grid md:grid-cols-2 gap-0 bg-white rounded-2xl overflow-hidden shadow-lg border border-neutral-200 text-left"
|
||||||
>
|
>
|
||||||
{problemStatement && (
|
{problemStatement && (
|
||||||
@@ -193,7 +193,7 @@ export function ServiceDetailGrid({
|
|||||||
{/* On a white card, a very subtle primary/grey shine works best */}
|
{/* On a white card, a very subtle primary/grey shine works best */}
|
||||||
<HoverShineOverlay shineColor="via-primary/5" />
|
<HoverShineOverlay shineColor="via-primary/5" />
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start mb-12 gap-6">
|
<div className="relative z-10 flex flex-row justify-between items-start mb-12 gap-6">
|
||||||
<div className="pt-2 flex-1 min-w-0 pr-4">
|
<div className="pt-2 flex-1 min-w-0 pr-4">
|
||||||
<h4 className="text-lg md:text-2xl font-extrabold text-neutral-dark uppercase tracking-wider text-balance break-words" lang="de">{panel.title}</h4>
|
<h4 className="text-lg md:text-2xl font-extrabold text-neutral-dark uppercase tracking-wider text-balance break-words" lang="de">{panel.title}</h4>
|
||||||
<div className="h-1 w-12 bg-primary mt-6 transition-all duration-700 group-hover:w-24 rounded-full"></div>
|
<div className="h-1 w-12 bg-primary mt-6 transition-all duration-700 group-hover:w-24 rounded-full"></div>
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
})) || defaultCompanies;
|
})) || defaultCompanies;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="unternehmen" className="py-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
<section id="unternehmen" className="pt-12 pb-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
||||||
<div className="container relative z-10 px-4">
|
<div className="container relative z-10 px-4">
|
||||||
|
|
||||||
{(badge || title) && (
|
{(badge || title) && (
|
||||||
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
@@ -228,7 +228,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sharedClass = `group relative overflow-hidden aspect-[4/3] flex flex-col justify-center items-center shadow-xl transition-all duration-500 bg-neutral-dark block w-full h-full sm:rounded-xl select-none ${
|
const sharedClass = `group relative overflow-hidden aspect-[4/3] flex flex-col justify-center items-center shadow-xl transition-all duration-500 bg-neutral-dark block w-full h-full rounded-2xl select-none ${
|
||||||
isCurrent
|
isCurrent
|
||||||
? 'border border-white/5 hover:shadow-2xl hover:-translate-y-1 hover:border-white/10 cursor-pointer'
|
? 'border border-white/5 hover:shadow-2xl hover:-translate-y-1 hover:border-white/10 cursor-pointer'
|
||||||
: 'border border-white/5 hover:shadow-2xl hover:-translate-y-1 hover:border-white/10 cursor-pointer'
|
: 'border border-white/5 hover:shadow-2xl hover:-translate-y-1 hover:border-white/10 cursor-pointer'
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
|
|||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
className="mt-12 md:mt-24 p-6 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group"
|
className="mt-12 md:mt-24 p-6 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 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" />
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
{members.map((member, i) => (
|
{members.map((member, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
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" }}
|
||||||
transition={{ delay: i * 0.1, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden"
|
className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Card Banner */}
|
{/* Card Banner */}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-[#050B14] text-neutral-light pt-40 pb-10 md:pt-40 md:pb-16 relative overflow-hidden border-t border-white/5">
|
<footer className="bg-[#050B14] text-neutral-light pt-24 pb-28 md:pt-32 md:pb-16 relative overflow-hidden border-t border-white/5">
|
||||||
{/* Subtle background tech grid */}
|
{/* Subtle background tech grid */}
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:32px_32px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_100%)] pointer-events-none" />
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:32px_32px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_100%)] pointer-events-none" />
|
||||||
|
|
||||||
@@ -108,8 +108,13 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
{locale === 'de' ? 'Navigation' : 'Navigation'}
|
{locale === 'de' ? 'Navigation' : 'Navigation'}
|
||||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-2 mb-8">
|
<ul className="grid grid-cols-2 gap-x-8 gap-y-3 mb-8">
|
||||||
{[
|
{[
|
||||||
|
{ label: locale === 'de' ? 'Unternehmen' : 'Company', href: `/${locale}/${locale === 'de' ? 'ueber-uns' : 'about-us'}` },
|
||||||
|
{ label: locale === 'de' ? 'Standorte' : 'Locations', href: `/${locale}/standorte` },
|
||||||
|
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
||||||
|
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/${locale === 'de' ? 'karriere' : 'career'}` },
|
||||||
|
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
||||||
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/${locale === 'de' ? 'kompetenzen' : 'competencies'}` },
|
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/${locale === 'de' ? 'kompetenzen' : 'competencies'}` },
|
||||||
{ label: locale === 'de' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
{ label: locale === 'de' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
||||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
||||||
@@ -123,28 +128,6 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-4 text-white">
|
|
||||||
<TransitionLink href={`/${locale}/${locale === 'de' ? 'ueber-uns' : 'about-us'}`} className="hover:text-primary transition-colors cursor-pointer inline-block">
|
|
||||||
{locale === 'de' ? 'Über uns' : 'About us'}
|
|
||||||
</TransitionLink>
|
|
||||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-2 border-l border-white/10 pl-4 ml-1">
|
|
||||||
{[
|
|
||||||
{ label: locale === 'de' ? 'Standorte' : 'Locations', href: `/${locale}/standorte` },
|
|
||||||
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
|
||||||
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
|
||||||
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/${locale === 'de' ? 'karriere' : 'career'}` },
|
|
||||||
].map((link) => (
|
|
||||||
<li key={link.href}>
|
|
||||||
<TransitionLink href={link.href} className="group/link flex items-center text-neutral-400 hover:text-white transition-colors py-1 cursor-pointer">
|
|
||||||
<span className="w-0 h-px bg-primary mr-0 group-hover/link:w-4 group-hover/link:mr-3 transition-all duration-300" />
|
|
||||||
<span className="font-medium">{link.label}</span>
|
|
||||||
</TransitionLink>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile View (Accordions) */}
|
{/* Mobile View (Accordions) */}
|
||||||
@@ -157,6 +140,11 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
<div className="pt-2 pb-4">
|
<div className="pt-2 pb-4">
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{[
|
{[
|
||||||
|
{ label: locale === 'de' ? 'Unternehmen' : 'Company', href: `/${locale}/${locale === 'de' ? 'ueber-uns' : 'about-us'}` },
|
||||||
|
{ label: locale === 'de' ? 'Standorte' : 'Locations', href: `/${locale}/standorte` },
|
||||||
|
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
||||||
|
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/${locale === 'de' ? 'karriere' : 'career'}` },
|
||||||
|
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
||||||
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/${locale === 'de' ? 'kompetenzen' : 'competencies'}` },
|
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/${locale === 'de' ? 'kompetenzen' : 'competencies'}` },
|
||||||
{ label: locale === 'de' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
{ label: locale === 'de' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
||||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
||||||
@@ -171,32 +159,6 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div className="h-px bg-white/10 w-full"></div>
|
|
||||||
|
|
||||||
<details className="group/acc">
|
|
||||||
<summary className="font-heading font-bold uppercase tracking-widest text-sm text-white list-none cursor-pointer flex justify-between items-center py-2 outline-none select-none">
|
|
||||||
{locale === 'de' ? 'Über uns' : 'About us'}
|
|
||||||
<svg className="w-4 h-4 text-white/50 transition-transform group-open/acc:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 9l-7 7-7-7"/></svg>
|
|
||||||
</summary>
|
|
||||||
<div className="pt-2 pb-4">
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{[
|
|
||||||
{ label: locale === 'de' ? 'Unternehmen' : 'Company', href: `/${locale}/${locale === 'de' ? 'ueber-uns' : 'about-us'}` },
|
|
||||||
{ label: locale === 'de' ? 'Standorte' : 'Locations', href: `/${locale}/standorte` },
|
|
||||||
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
|
||||||
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
|
||||||
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/${locale === 'de' ? 'karriere' : 'career'}` },
|
|
||||||
].map((link) => (
|
|
||||||
<li key={link.href}>
|
|
||||||
<TransitionLink href={link.href} className="flex items-center text-neutral-400 hover:text-white transition-colors py-1">
|
|
||||||
<span className="font-medium">{link.label}</span>
|
|
||||||
</TransitionLink>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
fill
|
fill
|
||||||
className="object-contain object-left group-hover:scale-105 transition-transform duration-500 origin-left"
|
className="object-contain object-left group-hover:scale-105 transition-transform duration-500 origin-left"
|
||||||
priority
|
priority
|
||||||
|
fetchPriority="high"
|
||||||
sizes="192px"
|
sizes="192px"
|
||||||
/>
|
/>
|
||||||
{/* Masked sweep overlay on hover */}
|
{/* Masked sweep overlay on hover */}
|
||||||
@@ -138,8 +139,54 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
<div className="flex items-center gap-2 md:gap-4 lg:gap-6">
|
<div className="flex items-center gap-2 md:gap-4 lg:gap-6">
|
||||||
<div className={`hidden md:block h-5 w-px transition-colors duration-300 ${isSolidMode ? 'bg-neutral-200' : 'bg-white/20'}`} />
|
<div className={`hidden md:block h-5 w-px transition-colors duration-300 ${isSolidMode ? 'bg-neutral-200' : 'bg-white/20'}`} />
|
||||||
|
|
||||||
{/* Language Switcher visible on all devices */}
|
{/* Quick Actions (Mobile Only) */}
|
||||||
<div className="z-50 relative flex items-center">
|
<div className="md:hidden flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href="tel:+492572946760"
|
||||||
|
className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-300 ${
|
||||||
|
isSolidMode
|
||||||
|
? 'bg-primary text-white shadow-md shadow-primary/20 hover:-translate-y-0.5'
|
||||||
|
: 'bg-white text-primary shadow-md hover:-translate-y-0.5'
|
||||||
|
}`}
|
||||||
|
aria-label="Anrufen"
|
||||||
|
>
|
||||||
|
<svg className="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:info@e-tib.com"
|
||||||
|
className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-300 ${
|
||||||
|
isSolidMode
|
||||||
|
? 'bg-neutral-100 text-neutral-600 hover:text-primary hover:bg-neutral-200'
|
||||||
|
: 'bg-white/10 text-white backdrop-blur-md border border-white/20 hover:bg-white/20'
|
||||||
|
}`}
|
||||||
|
aria-label="E-Mail"
|
||||||
|
>
|
||||||
|
<svg className="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://maps.google.com/?q=E-TIB+GmbH,+Greven"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-300 ${
|
||||||
|
isSolidMode
|
||||||
|
? 'bg-neutral-100 text-neutral-600 hover:text-primary hover:bg-neutral-200'
|
||||||
|
: 'bg-white/10 text-white backdrop-blur-md border border-white/20 hover:bg-white/20'
|
||||||
|
}`}
|
||||||
|
aria-label="Route"
|
||||||
|
>
|
||||||
|
<svg className="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Language Switcher visible on desktop only */}
|
||||||
|
<div className="z-50 relative hidden md:flex items-center">
|
||||||
<LanguageSwitcher isSolidMode={isSolidMode} />
|
<LanguageSwitcher isSolidMode={isSolidMode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,81 +204,10 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
<span className="absolute top-0 -left-[150%] h-[200%] w-[150%] bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg] group-hover/cta:left-[100%] transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] z-30 pointer-events-none" />
|
<span className="absolute top-0 -left-[150%] h-[200%] w-[150%] bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg] group-hover/cta:left-[100%] transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] z-30 pointer-events-none" />
|
||||||
</TransitionLink>
|
</TransitionLink>
|
||||||
|
|
||||||
{/* Hamburger Menu (Mobile Only) */}
|
|
||||||
<button
|
|
||||||
className={`md:hidden ml-2 p-2 transition-colors focus:outline-none ${isSolidMode && !isMobileMenuOpen ? 'text-text-primary hover:text-primary' : 'text-white hover:text-primary z-[60] relative'}`}
|
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
||||||
aria-label="Toggle Menu"
|
|
||||||
>
|
|
||||||
<div className="w-6 flex flex-col gap-[6px] relative items-center justify-center">
|
|
||||||
<span className={`w-full h-[2px] rounded-full bg-current transform transition-all duration-300 ease-in-out origin-center ${isMobileMenuOpen ? 'rotate-45 translate-y-[8px]' : ''}`} />
|
|
||||||
<span className={`w-full h-[2px] rounded-full bg-current transition-all duration-300 ease-in-out ${isMobileMenuOpen ? 'opacity-0 translate-x-3' : 'opacity-100'}`} />
|
|
||||||
<span className={`w-full h-[2px] rounded-full bg-current transform transition-all duration-300 ease-in-out origin-center ${isMobileMenuOpen ? '-rotate-45 -translate-y-[8px]' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{isMobileMenuOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="fixed inset-0 z-[40] bg-[#050B14]/95 backdrop-blur-xl md:hidden pt-24 pb-24 px-6 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<nav className="flex flex-col gap-6 mt-8">
|
|
||||||
{navLinks && navLinks.length > 0 && navLinks.map((link) => {
|
|
||||||
const mappedUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
|
|
||||||
? `/${currentLocale}${link.url}`
|
|
||||||
: link.url;
|
|
||||||
const isActive = pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={link.url} className="flex flex-col">
|
|
||||||
<TransitionLink
|
|
||||||
href={mappedUrl}
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
className={`text-2xl font-bold uppercase tracking-widest transition-colors ${isActive ? 'text-primary' : 'text-white'}`}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</TransitionLink>
|
|
||||||
|
|
||||||
{link.children && (
|
|
||||||
<div className="flex flex-col gap-3 mt-3 pl-4 border-l-2 border-white/10">
|
|
||||||
{link.children.map((child, idx) => {
|
|
||||||
if (child.isGroupLabel) {
|
|
||||||
return <div key={idx} className="text-sm font-bold text-neutral-500 uppercase tracking-widest mt-2">{child.label}</div>;
|
|
||||||
}
|
|
||||||
const childUrl = child.url.startsWith('/') && !child.url.match(/^\/(en|de)/)
|
|
||||||
? `/${currentLocale}${child.url}`
|
|
||||||
: child.url;
|
|
||||||
const isChildActive = pathname === childUrl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TransitionLink
|
|
||||||
key={child.url}
|
|
||||||
href={childUrl}
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
className={`text-lg font-medium transition-colors ${isChildActive ? 'text-primary' : 'text-white/70'}`}
|
|
||||||
>
|
|
||||||
{child.label}
|
|
||||||
</TransitionLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
import * as React from 'react';
|
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, Briefcase, Users, Mail } from 'lucide-react';
|
import { Home, Layers, Star, Mail, Menu } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
export interface NavLink {
|
export interface NavLink {
|
||||||
label: string;
|
label: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
children?: NavLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileBottomNavProps {
|
interface MobileBottomNavProps {
|
||||||
@@ -18,84 +19,213 @@ interface MobileBottomNavProps {
|
|||||||
|
|
||||||
export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProps) {
|
export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProps) {
|
||||||
const pathname = usePathname() || '/';
|
const pathname = usePathname() || '/';
|
||||||
|
const [isFlyoutOpen, setIsFlyoutOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Scroll lock when flyout is open
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isFlyoutOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.documentElement.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.documentElement.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isFlyoutOpen]);
|
||||||
|
|
||||||
// Construct our bottom nav items based on the passed links
|
|
||||||
const items = React.useMemo(() => {
|
const items = React.useMemo(() => {
|
||||||
const homeUrl = `/${currentLocale}`;
|
const homeUrl = `/${currentLocale}`;
|
||||||
|
|
||||||
// Find the specific links from the provided navLinks (which might be translated)
|
|
||||||
// We do a simple match based on index or keywords if needed, but since we know the order:
|
|
||||||
// 0: Kompetenzen, 1: Über uns, 2: Karriere, 3: Messen
|
|
||||||
const competenciesUrl = navLinks[0]?.url || `/${currentLocale}/kompetenzen`;
|
const competenciesUrl = navLinks[0]?.url || `/${currentLocale}/kompetenzen`;
|
||||||
const aboutUrl = navLinks[1]?.url || `/${currentLocale}/ueber-uns`;
|
const referencesUrl = navLinks[2]?.url || `/${currentLocale}/referenzen`;
|
||||||
const contactUrl = `/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`;
|
const contactUrl = `/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
id: 'home',
|
||||||
label: currentLocale === 'de' ? 'Start' : 'Home',
|
label: currentLocale === 'de' ? 'Start' : 'Home',
|
||||||
url: homeUrl,
|
url: homeUrl,
|
||||||
icon: <Home className="w-6 h-6 mb-1" strokeWidth={2} />,
|
icon: <Home className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />,
|
||||||
exactMatch: true
|
exactMatch: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'kompetenzen',
|
||||||
label: navLinks[0]?.label || (currentLocale === 'de' ? 'Kompetenzen' : 'Competencies'),
|
label: navLinks[0]?.label || (currentLocale === 'de' ? 'Kompetenzen' : 'Competencies'),
|
||||||
url: competenciesUrl,
|
url: competenciesUrl,
|
||||||
icon: <Briefcase className="w-6 h-6 mb-1" strokeWidth={2} />
|
icon: <Layers className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: navLinks[1]?.label || (currentLocale === 'de' ? 'Über uns' : 'About Us'),
|
id: 'referenzen',
|
||||||
url: aboutUrl,
|
label: navLinks[2]?.label || (currentLocale === 'de' ? 'Referenzen' : 'References'),
|
||||||
icon: <Users className="w-6 h-6 mb-1" strokeWidth={2} />
|
url: referencesUrl,
|
||||||
|
icon: <Star className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'kontakt',
|
||||||
label: currentLocale === 'de' ? 'Kontakt' : 'Contact',
|
label: currentLocale === 'de' ? 'Kontakt' : 'Contact',
|
||||||
url: contactUrl,
|
url: contactUrl,
|
||||||
icon: <Mail className="w-6 h-6 mb-1" strokeWidth={2} />
|
icon: <Mail className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu',
|
||||||
|
label: currentLocale === 'de' ? 'Mehr' : 'Menu',
|
||||||
|
url: '#',
|
||||||
|
icon: <Menu className="w-[24px] h-[24px] md:w-6 md:h-6" strokeWidth={2.5} />,
|
||||||
|
isFlyoutTrigger: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [currentLocale, navLinks]);
|
}, [currentLocale, navLinks]);
|
||||||
|
|
||||||
return (
|
const flyoutLinks = React.useMemo(() => {
|
||||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 px-2 pb-6 pt-2 bg-gradient-to-t from-white via-white to-white/90 backdrop-blur-xl border-t border-neutral-200/50 shadow-[0_-8px_32px_rgba(0,0,0,0.05)] safe-area-bottom">
|
const aboutNode = navLinks[1];
|
||||||
<nav className="flex justify-around items-center max-w-md mx-auto">
|
const karriereNode = navLinks[3];
|
||||||
{items.map((item) => {
|
const messenNode = navLinks[4];
|
||||||
// Determine if active
|
|
||||||
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
let links: {label: string, url: string}[] = [];
|
||||||
? `/${currentLocale}${item.url}`
|
if (aboutNode?.children) {
|
||||||
: item.url;
|
links.push(...aboutNode.children.map(c => ({ label: c.label, url: c.url })));
|
||||||
|
}
|
||||||
const isActive = item.exactMatch
|
if (karriereNode) links.push({ label: karriereNode.label, url: karriereNode.url });
|
||||||
? pathname === mappedUrl
|
if (messenNode) links.push({ label: messenNode.label, url: messenNode.url });
|
||||||
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
|
return links;
|
||||||
|
}, [navLinks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransitionLink
|
<>
|
||||||
key={item.label}
|
<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">
|
||||||
href={mappedUrl}
|
<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]">
|
||||||
className={`relative flex flex-col items-center justify-center w-full min-h-[44px] py-1 transition-colors duration-300 select-none ${
|
{items.map((item) => {
|
||||||
isActive ? 'text-primary' : 'text-neutral-500 hover:text-neutral-900'
|
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
||||||
}`}
|
? `/${currentLocale}${item.url}`
|
||||||
>
|
: item.url;
|
||||||
<div className="relative flex flex-col items-center justify-center">
|
|
||||||
{/* Active Icon Background */}
|
let isActive = false;
|
||||||
|
if (item.isFlyoutTrigger) {
|
||||||
|
isActive = isFlyoutOpen;
|
||||||
|
} else {
|
||||||
|
isActive = item.exactMatch
|
||||||
|
? pathname === mappedUrl
|
||||||
|
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerContent = (
|
||||||
|
<>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="mobile-nav-active-bg"
|
layoutId="mobile-nav-active-bg"
|
||||||
className="absolute inset-0 bg-primary/10 rounded-full scale-150"
|
className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10"
|
||||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Icon wrapper to ensure it sits above the background */}
|
<div className="relative flex flex-col items-center justify-center z-10 w-full h-full">
|
||||||
<div className="relative z-10 transition-transform duration-300 active:scale-90">
|
<motion.div
|
||||||
{item.icon}
|
whileTap={{ scale: 0.8 }}
|
||||||
|
animate={{ y: isActive ? -2 : 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</motion.div>
|
||||||
|
<motion.span
|
||||||
|
animate={{ y: isActive ? 0 : 2 }}
|
||||||
|
className="text-[9px] font-bold mt-1 tracking-wide"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</motion.span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.isFlyoutTrigger) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
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 ${
|
||||||
|
isActive ? 'text-primary' : 'text-neutral-600 hover:text-neutral-900'
|
||||||
|
}`}
|
||||||
|
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
||||||
|
>
|
||||||
|
{innerContent}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransitionLink
|
||||||
|
key={item.label}
|
||||||
|
href={mappedUrl}
|
||||||
|
onClick={() => setIsFlyoutOpen(false)}
|
||||||
|
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none ${
|
||||||
|
isActive ? 'text-primary' : 'text-neutral-600 hover:text-neutral-900'
|
||||||
|
}`}
|
||||||
|
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
||||||
|
>
|
||||||
|
{innerContent}
|
||||||
|
</TransitionLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isFlyoutOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[9997] md:hidden"
|
||||||
|
onClick={() => setIsFlyoutOpen(false)}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
|
||||||
|
drag="y"
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
dragElastic={0.2}
|
||||||
|
onDragEnd={(e, info) => {
|
||||||
|
if (info.offset.y > 50 || info.velocity.y > 100) {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{flyoutLinks.map((link, i) => {
|
||||||
|
const childUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
|
||||||
|
? `/${currentLocale}${link.url}`
|
||||||
|
: link.url;
|
||||||
|
const isChildActive = pathname === childUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransitionLink
|
||||||
|
key={i}
|
||||||
|
href={childUrl}
|
||||||
|
onClick={() => setIsFlyoutOpen(false)}
|
||||||
|
className={`px-4 py-4 rounded-2xl text-[15px] font-semibold transition-all flex items-center justify-between ${
|
||||||
|
isChildActive ? 'bg-primary/10 text-primary' : 'text-neutral-700 hover:bg-neutral-50 active:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
<svg className={`w-4 h-4 ${isChildActive ? 'text-primary' : 'text-neutral-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</TransitionLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-semibold mt-0.5 tracking-wide z-10">{item.label}</span>
|
</motion.div>
|
||||||
</TransitionLink>
|
</>
|
||||||
);
|
)}
|
||||||
})}
|
</AnimatePresence>
|
||||||
</nav>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,51 +4,29 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
const PRELOAD_VIDEOS = [
|
|
||||||
'/assets/videos/web/hero-bohrung.mp4'
|
|
||||||
];
|
|
||||||
|
|
||||||
export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?: boolean }) {
|
export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?: boolean }) {
|
||||||
const [isLoading, setIsLoading] = useState(shouldShowLoader);
|
const [isLoading, setIsLoading] = useState(shouldShowLoader);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldShowLoader) return;
|
|
||||||
|
|
||||||
// Fallback Timeout (4 Sekunden), falls Videos hängen
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
finishLoading();
|
|
||||||
}, 4000);
|
|
||||||
|
|
||||||
let videosLoaded = 0;
|
|
||||||
|
|
||||||
const checkAllLoaded = () => {
|
|
||||||
videosLoaded++;
|
|
||||||
if (videosLoaded === PRELOAD_VIDEOS.length) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
// Kleine Verzögerung für die smootheness
|
|
||||||
setTimeout(finishLoading, 800);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
PRELOAD_VIDEOS.forEach((src) => {
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.preload = 'auto';
|
|
||||||
video.oncanplaythrough = checkAllLoaded;
|
|
||||||
video.onerror = checkAllLoaded; // bei Fehler trotzdem weitermachen
|
|
||||||
video.src = src;
|
|
||||||
video.load();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [shouldShowLoader]);
|
|
||||||
|
|
||||||
const finishLoading = () => {
|
const finishLoading = () => {
|
||||||
// Set a session cookie so the server knows not to render the loader again
|
// Set a session cookie so the server knows not to render the loader again
|
||||||
document.cookie = "etib_initial_loader_v5=true; path=/; samesite=lax";
|
document.cookie = "etib_initial_loader_v5=true; path=/; samesite=lax";
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldShowLoader) return;
|
||||||
|
|
||||||
|
// Just show the animation for a short time to allow UI to mount,
|
||||||
|
// we don't preload 10MB videos anymore to save pagespeed!
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
finishLoading();
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [shouldShowLoader]);
|
||||||
|
|
||||||
// Wenn wir serverseitig rendern und es nicht zeigen sollen, return null
|
// Wenn wir serverseitig rendern und es nicht zeigen sollen, return null
|
||||||
if (!shouldShowLoader) return null;
|
if (!shouldShowLoader) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ 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 router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -24,18 +22,6 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
|||||||
isTransitioningRef.current = isTransitioning;
|
isTransitioningRef.current = isTransitioning;
|
||||||
}, [isTransitioning]);
|
}, [isTransitioning]);
|
||||||
|
|
||||||
// Watch for route changes to complete the transition
|
|
||||||
useEffect(() => {
|
|
||||||
if (isTransitioningRef.current) {
|
|
||||||
// The route has actually changed in the browser, or at least the RSC payload arrived
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsTransitioning(false);
|
|
||||||
setTimeout(() => setTransitionMessage(null), 300);
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [pathname, searchParams]); // Run when pathname or search parameters change
|
|
||||||
|
|
||||||
const startTransition = useCallback((href: string, message: string | null = null) => {
|
const startTransition = useCallback((href: string, message: string | null = null) => {
|
||||||
// Wenn wir bereits navigieren, nichts tun
|
// Wenn wir bereits navigieren, nichts tun
|
||||||
if (isTransitioning) return;
|
if (isTransitioning) return;
|
||||||
@@ -75,11 +61,35 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TransitionContext.Provider value={{ isTransitioning, transitionMessage, startTransition }}>
|
<TransitionContext.Provider value={{ isTransitioning, transitionMessage, startTransition }}>
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<TransitionRouteWatcher
|
||||||
|
isTransitioningRef={isTransitioningRef}
|
||||||
|
setIsTransitioning={setIsTransitioning}
|
||||||
|
setTransitionMessage={setTransitionMessage}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
{children}
|
{children}
|
||||||
</TransitionContext.Provider>
|
</TransitionContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TransitionRouteWatcher({ isTransitioningRef, setIsTransitioning, setTransitionMessage }: any) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTransitioningRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsTransitioning(false);
|
||||||
|
setTimeout(() => setTransitionMessage(null), 300);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [pathname, searchParams, setIsTransitioning, setTransitionMessage, isTransitioningRef]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function useTransition() {
|
export function useTransition() {
|
||||||
const context = useContext(TransitionContext);
|
const context = useContext(TransitionContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { ReactNode } from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import React, { ReactNode, useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -6,10 +8,33 @@ interface TooltipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Tooltip({ children, content }: TooltipProps) {
|
export function Tooltip({ children, content }: TooltipProps) {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
document.addEventListener('touchstart', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('touchstart', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex items-center justify-center">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="group relative flex items-center justify-center cursor-help"
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
onMouseEnter={() => setIsActive(true)}
|
||||||
|
onMouseLeave={() => setIsActive(false)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<div className="absolute bottom-full mb-3 invisible opacity-0 translate-y-2 group-hover:visible group-hover:opacity-100 group-hover:translate-y-0 w-max max-w-[250px] px-4 py-2.5 text-[11px] md:text-xs text-white bg-neutral-900/95 backdrop-blur-md rounded-xl shadow-2xl border border-white/10 transition-all duration-300 pointer-events-none z-50 text-center leading-relaxed font-normal normal-case tracking-normal">
|
<div className={`absolute bottom-full mb-3 w-max max-w-[250px] px-4 py-2.5 text-[11px] md:text-xs text-white bg-neutral-900/95 backdrop-blur-md rounded-xl shadow-2xl border border-white/10 transition-all duration-300 z-50 text-center leading-relaxed font-normal normal-case tracking-normal ${isActive ? 'visible opacity-100 translate-y-0 pointer-events-auto' : 'invisible opacity-0 translate-y-2 pointer-events-none'}`}>
|
||||||
{content}
|
{content}
|
||||||
{/* Triangle Arrow */}
|
{/* Triangle Arrow */}
|
||||||
<div className="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-neutral-900/95" />
|
<div className="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-neutral-900/95" />
|
||||||
|
|||||||
BIN
contact_sheet.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
@@ -79,8 +79,8 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
|
|||||||
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Mitarbeitende</div>
|
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Mitarbeitende</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-4xl font-black text-primary mb-1 tracking-tighter">100+</div>
|
<div className="text-4xl font-black text-primary mb-1 tracking-tighter">200+</div>
|
||||||
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Kunden</div>
|
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Projekte</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +88,7 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
|
|||||||
{/* Right Column: Imagery & Mission */}
|
{/* Right Column: Imagery & Mission */}
|
||||||
<div className="lg:col-span-7 relative">
|
<div className="lg:col-span-7 relative">
|
||||||
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
|
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
|
||||||
<img src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB im Einsatz" className="w-full h-full object-cover" />
|
<img src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB im Einsatz" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
||||||
<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">
|
||||||
@@ -122,7 +122,7 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
|
|||||||
tag: "Energie",
|
tag: "Energie",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/de/kabeltiefbau",
|
href: "/de/kabeltiefbau",
|
||||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Kabelleitungsnetzbau" }
|
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Bohrtechnik",
|
title: "Bohrtechnik",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ layout: "fullBleed"
|
|||||||
tag: "Energie",
|
tag: "Energie",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/de/kabeltiefbau",
|
href: "/de/kabeltiefbau",
|
||||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Kabelleitungsnetzbau" }
|
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Bohrtechnik",
|
title: "Bohrtechnik",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "Energiekontor AG"
|
|||||||
date: "2026-06-01"
|
date: "2026-06-01"
|
||||||
dateString: "01/2025 bis 06/2026"
|
dateString: "01/2025 bis 06/2026"
|
||||||
location: "18334 Dettmannsdorf"
|
location: "18334 Dettmannsdorf"
|
||||||
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-256.jpg"
|
featuredImage: "/assets/photos/DJI_0301.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- 12.300 m Kabelgraben ausheben und verfüllen
|
- 12.300 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ client: "wpd GmbH"
|
|||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
dateString: "seit 03/2026"
|
dateString: "seit 03/2026"
|
||||||
location: "24808 Jevenstedt"
|
location: "24808 Jevenstedt"
|
||||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-15.jpg"
|
featuredImage: "/assets/photos/DJI_0037.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- 500 m Kabelgraben ausheben und verfüllen
|
- 500 m Kabelgraben ausheben und verfüllen
|
||||||
- 4.500 m Einpflügen von 3 x PE-Schutzrohr d 160
|
- 4.500 m Einpflügen von 3 x PE-Schutzrohr d 160
|
||||||
- 3.200 m Horizontalspülbohrung d 160
|
- 12.000 m Horizontalspülbohrung d 160
|
||||||
- 8.500 m Verlegung HS-Kabel 1.000 mm² 64/110 kV
|
- 8.500 m Verlegung HS-Kabel 1.000 mm² 64/110 kV
|
||||||
- 11 Satz Verbindungsmuffen 1.000 mm² 64/110 kV
|
- 11 Satz Verbindungsmuffen 1.000 mm² 64/110 kV
|
||||||
- 2 Satz Freiluftendverschlüsse 1.000 mm² 64/110 kV
|
- 2 Satz Freiluftendverschlüsse 1.000 mm² 64/110 kV
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "E.DIS Netz GmbH"
|
|||||||
date: "2025-06-01"
|
date: "2025-06-01"
|
||||||
dateString: "01/2025 bis 06/2025"
|
dateString: "01/2025 bis 06/2025"
|
||||||
location: "14669 Ketzin"
|
location: "14669 Ketzin"
|
||||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-14.jpg"
|
featuredImage: "/assets/photos/DSC02643.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- 1.600 m Kabelgraben ausheben und verfüllen
|
- 1.600 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
|
|||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
dateString: "02/2025 bis 03/2026"
|
dateString: "02/2025 bis 03/2026"
|
||||||
location: "48346 Ostbevern"
|
location: "48346 Ostbevern"
|
||||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg"
|
featuredImage: "/assets/photos/DJI_0048.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- 4.100 m Kabelgraben ausheben und verfüllen
|
- 4.100 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
|
|||||||
date: "2025-02-01"
|
date: "2025-02-01"
|
||||||
dateString: "04/2024 bis 02/2025"
|
dateString: "04/2024 bis 02/2025"
|
||||||
location: "15326 Petershagen"
|
location: "15326 Petershagen"
|
||||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-12.jpg"
|
featuredImage: "/assets/photos/Screenshot_2025-09-30_085736.png"
|
||||||
---
|
---
|
||||||
|
|
||||||
- 12.000 m Kabelgraben ausheben und verfüllen
|
- 12.000 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "Trianel GmbH"
|
|||||||
date: "2025-08-01"
|
date: "2025-08-01"
|
||||||
dateString: "02/2025 bis 08/2025"
|
dateString: "02/2025 bis 08/2025"
|
||||||
location: "15345 Prötzel"
|
location: "15345 Prötzel"
|
||||||
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-294.jpg"
|
featuredImage: "/assets/photos/DJI_0243.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- 2.600 m Kabelgraben ausheben und verfüllen
|
- 2.600 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ layout: "fullBleed"
|
|||||||
{
|
{
|
||||||
id: "martin-polan",
|
id: "martin-polan",
|
||||||
name: "Martin Polan",
|
name: "Martin Polan",
|
||||||
position: "Bauleiter",
|
position: "Bauleitung",
|
||||||
email: "m.polan@e-tib.com",
|
email: "m.polan@e-tib.com",
|
||||||
phone: "+49 171 3868167",
|
phone: "+49 171 3868167",
|
||||||
image: "/assets/photos/team/martin.jpg"
|
image: "/assets/photos/team/martin.jpg"
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
|
|||||||
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Employees</div>
|
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Employees</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-4xl font-black text-primary mb-1 tracking-tighter">100+</div>
|
<div className="text-4xl font-black text-primary mb-1 tracking-tighter">200+</div>
|
||||||
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Clients</div>
|
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Projects</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +88,7 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
|
|||||||
{/* Right Column: Imagery & Mission */}
|
{/* Right Column: Imagery & Mission */}
|
||||||
<div className="lg:col-span-7 relative">
|
<div className="lg:col-span-7 relative">
|
||||||
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
|
<div className="aspect-[4/3] md:aspect-[16/9] lg:aspect-[4/3] rounded-3xl overflow-hidden shadow-2xl relative border border-neutral-200/50">
|
||||||
<img src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB in action" className="w-full h-full object-cover" />
|
<img src="/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg" alt="E-TIB in action" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
||||||
<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">
|
||||||
@@ -122,7 +122,7 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
|
|||||||
tag: "Energy",
|
tag: "Energy",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/en/kabeltiefbau",
|
href: "/en/kabeltiefbau",
|
||||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Cable Civil Engineering" }
|
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Drilling Technology",
|
title: "Drilling Technology",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ layout: "fullBleed"
|
|||||||
tag: "Energy",
|
tag: "Energy",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/en/kabeltiefbau",
|
href: "/en/kabeltiefbau",
|
||||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Cable Civil Engineering" }
|
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Drilling Technology",
|
title: "Drilling Technology",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "Energiekontor AG"
|
|||||||
date: "2026-06-01"
|
date: "2026-06-01"
|
||||||
dateString: "01/2025 to 06/2026"
|
dateString: "01/2025 to 06/2026"
|
||||||
location: "18334 Dettmannsdorf"
|
location: "18334 Dettmannsdorf"
|
||||||
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-256.jpg"
|
featuredImage: "/assets/photos/DJI_0301.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- Excavation and backfilling of 12,300 m cable trench
|
- Excavation and backfilling of 12,300 m cable trench
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ client: "wpd GmbH"
|
|||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
dateString: "since 03/2026"
|
dateString: "since 03/2026"
|
||||||
location: "24808 Jevenstedt"
|
location: "24808 Jevenstedt"
|
||||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-15.jpg"
|
featuredImage: "/assets/photos/DJI_0037.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- Excavation and backfilling of 500 m cable trench
|
- Excavation and backfilling of 500 m cable trench
|
||||||
- 4,500 m plowing of 3 x PE protective pipe d 160
|
- 4,500 m plowing of 3 x PE protective pipe d 160
|
||||||
- 3,200 m HDD directional drilling d 160
|
- 12,000 m HDD directional drilling d 160
|
||||||
- 8,500 m laying of HV cable 1,000 mm² 64/110 kV
|
- 8,500 m laying of HV cable 1,000 mm² 64/110 kV
|
||||||
- 11 sets of connection joints 1,000 mm² 64/110 kV
|
- 11 sets of connection joints 1,000 mm² 64/110 kV
|
||||||
- 2 sets of outdoor terminations 1,000 mm² 64/110 kV
|
- 2 sets of outdoor terminations 1,000 mm² 64/110 kV
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "E.DIS Netz GmbH"
|
|||||||
date: "2025-06-01"
|
date: "2025-06-01"
|
||||||
dateString: "01/2025 to 06/2025"
|
dateString: "01/2025 to 06/2025"
|
||||||
location: "14669 Ketzin"
|
location: "14669 Ketzin"
|
||||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-14.jpg"
|
featuredImage: "/assets/photos/DSC02643.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- Excavation and backfilling of 1,600 m cable trench
|
- Excavation and backfilling of 1,600 m cable trench
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
|
|||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
dateString: "02/2025 to 03/2026"
|
dateString: "02/2025 to 03/2026"
|
||||||
location: "48346 Ostbevern"
|
location: "48346 Ostbevern"
|
||||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg"
|
featuredImage: "/assets/photos/DJI_0048.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- Excavation and backfilling of 4,100 m cable trench
|
- Excavation and backfilling of 4,100 m cable trench
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
|
|||||||
date: "2025-02-01"
|
date: "2025-02-01"
|
||||||
dateString: "04/2024 to 02/2025"
|
dateString: "04/2024 to 02/2025"
|
||||||
location: "15326 Petershagen"
|
location: "15326 Petershagen"
|
||||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-12.jpg"
|
featuredImage: "/assets/photos/Screenshot_2025-09-30_085736.png"
|
||||||
---
|
---
|
||||||
|
|
||||||
- Excavation and backfilling of 12,000 m cable trench
|
- Excavation and backfilling of 12,000 m cable trench
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "Trianel GmbH"
|
|||||||
date: "2025-08-01"
|
date: "2025-08-01"
|
||||||
dateString: "02/2025 to 08/2025"
|
dateString: "02/2025 to 08/2025"
|
||||||
location: "15345 Prötzel"
|
location: "15345 Prötzel"
|
||||||
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-294.jpg"
|
featuredImage: "/assets/photos/DJI_0243.JPG"
|
||||||
---
|
---
|
||||||
|
|
||||||
- Excavation and backfilling of 2,600 m cable trench
|
- Excavation and backfilling of 2,600 m cable trench
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ layout: "fullBleed"
|
|||||||
{
|
{
|
||||||
id: "martin-polan",
|
id: "martin-polan",
|
||||||
name: "Martin Polan",
|
name: "Martin Polan",
|
||||||
position: "Site Manager",
|
position: "Site Management",
|
||||||
email: "m.polan@e-tib.com",
|
email: "m.polan@e-tib.com",
|
||||||
phone: "+49 171 3868167",
|
phone: "+49 171 3868167",
|
||||||
image: "/assets/photos/team/martin.jpg"
|
image: "/assets/photos/team/martin.jpg"
|
||||||
|
|||||||
5303
lh-report.json
Normal file
14117
lh-single.json
Normal file
@@ -189,62 +189,6 @@ export const minorLocations: Location[] = [
|
|||||||
y: 83.33,
|
y: 83.33,
|
||||||
description: 'fiber',
|
description: 'fiber',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'hdd-29436-meter-29436',
|
|
||||||
name: 'HDD 29436 Meter',
|
|
||||||
type: 'minor_node',
|
|
||||||
x: 46.89,
|
|
||||||
y: 41.34,
|
|
||||||
description: 'power',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hdd-28680-meter-28680',
|
|
||||||
name: 'HDD 28680 Meter',
|
|
||||||
type: 'minor_node',
|
|
||||||
x: 44.84,
|
|
||||||
y: 43.61,
|
|
||||||
description: 'power',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hdd-44655-meter-44655',
|
|
||||||
name: 'HDD 44655 Meter',
|
|
||||||
type: 'minor_node',
|
|
||||||
x: 49.36,
|
|
||||||
y: 48.57,
|
|
||||||
description: 'power',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'kabelverlegung-76727-meter-76727',
|
|
||||||
name: 'Kabelverlegung 76727 Meter',
|
|
||||||
type: 'minor_node',
|
|
||||||
x: 45.57,
|
|
||||||
y: 49.95,
|
|
||||||
description: 'power',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offener-tiefbau-21288-meter-21288',
|
|
||||||
name: 'Offener Tiefbau 21288 Meter',
|
|
||||||
type: 'minor_node',
|
|
||||||
x: 40.15,
|
|
||||||
y: 49.41,
|
|
||||||
description: 'power',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offener-tiefbau-30773-meter-30773',
|
|
||||||
name: 'Offener Tiefbau 30773 Meter',
|
|
||||||
type: 'minor_node',
|
|
||||||
x: 41.74,
|
|
||||||
y: 43.78,
|
|
||||||
description: 'power',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'offener-tiefbau-34815-meter-34815',
|
|
||||||
name: 'Offener Tiefbau 34815 Meter',
|
|
||||||
type: 'minor_node',
|
|
||||||
x: 43.91,
|
|
||||||
y: 42.14,
|
|
||||||
description: 'power',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'pv-anlage-01909-grosshartau-01909',
|
id: 'pv-anlage-01909-grosshartau-01909',
|
||||||
name: 'PV-Anlage, 01909 Großhartau',
|
name: 'PV-Anlage, 01909 Großhartau',
|
||||||
|
|||||||
@@ -11,10 +11,21 @@ export async function getOgFonts() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
||||||
const boldFont = readFileSync(boldFontPath);
|
const boldFontBuffer = readFileSync(boldFontPath);
|
||||||
const regularFont = readFileSync(regularFontPath);
|
const regularFontBuffer = readFileSync(regularFontPath);
|
||||||
|
|
||||||
|
// Satori strictly requires an ArrayBuffer.
|
||||||
|
const boldFont = boldFontBuffer.buffer.slice(
|
||||||
|
boldFontBuffer.byteOffset,
|
||||||
|
boldFontBuffer.byteOffset + boldFontBuffer.byteLength,
|
||||||
|
);
|
||||||
|
const regularFont = regularFontBuffer.buffer.slice(
|
||||||
|
regularFontBuffer.byteOffset,
|
||||||
|
regularFontBuffer.byteOffset + regularFontBuffer.byteLength,
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -37,6 +48,28 @@ export async function getOgFonts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOgBackground() {
|
||||||
|
try {
|
||||||
|
const bgPath = join(process.cwd(), 'public/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg');
|
||||||
|
const bgBase64 = readFileSync(bgPath, 'base64');
|
||||||
|
return `data:image/jpeg;base64,${bgBase64}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OG] Failed to load background', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOgLogo() {
|
||||||
|
try {
|
||||||
|
const logoPath = join(process.cwd(), 'public/assets/logo-white.png');
|
||||||
|
const logoBase64 = readFileSync(logoPath, 'base64');
|
||||||
|
return `data:image/png;base64,${logoBase64}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OG] Failed to load logo', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common configuration for OG images
|
* Common configuration for OG images
|
||||||
*/
|
*/
|
||||||
|
|||||||
7
lib/placeholders.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/assets/photos/DJI_0048.JPG": "data:image/webp;base64,UklGRjIAAABXRUJQVlA4ICYAAADwAQCdASoKAAcABUB8JYwCdH8AGDNIbaAA92kbyv6vgOVMTIAAAA==",
|
||||||
|
"/assets/videos/web/hero-bahnkreuzung-poster.jpg": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoKAAYABUB8JaQAA3AA/vDUoAA=",
|
||||||
|
"/assets/videos/web/hero-bohrung-poster.jpg": "data:image/webp;base64,UklGRjIAAABXRUJQVlA4ICYAAACwAQCdASoKAAYABUB8JZACdAEOO2gAAN5Rr3co8j7CDYNKNCAAAA==",
|
||||||
|
"/assets/videos/web/hero-kabelpflug-poster.jpg": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoKAAYABUB8JaQAA3AA/vDUoAA=",
|
||||||
|
"/assets/videos/web/hero-messe-poster.jpg": "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoKAAYABUB8JaQAA3AA/vDUoAA="
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export const standorteData: StandortData[] = [
|
|||||||
{
|
{
|
||||||
id: 'guben',
|
id: 'guben',
|
||||||
name: 'E-TIB GmbH',
|
name: 'E-TIB GmbH',
|
||||||
shortName: 'Guben (Hauptsitz)',
|
shortName: 'Standort Brandenburg (Hauptsitz)',
|
||||||
type: 'hq',
|
type: 'hq',
|
||||||
description: {
|
description: {
|
||||||
de: 'Unser Hauptsitz in Guben bildet das strategische und operative Zentrum der E-TIB Gruppe. Von hier aus steuern wir bundesweit Großprojekte im Kabelleitungsnetzbau und koordinieren unseren hochmodernen Maschinenpark.',
|
de: 'Unser Hauptsitz in Guben bildet das strategische und operative Zentrum der E-TIB Gruppe. Von hier aus steuern wir bundesweit Großprojekte im Kabelleitungsnetzbau und koordinieren unseren hochmodernen Maschinenpark.',
|
||||||
@@ -67,7 +67,7 @@ export const standorteData: StandortData[] = [
|
|||||||
'Central project management'
|
'Central project management'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
image: '/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg',
|
image: '/assets/photos/DSC02114.jpg',
|
||||||
gallery: [
|
gallery: [
|
||||||
'/assets/photos/DSC02114.jpg',
|
'/assets/photos/DSC02114.jpg',
|
||||||
'/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-45.jpg'
|
'/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-45.jpg'
|
||||||
@@ -117,7 +117,7 @@ export const standorteData: StandortData[] = [
|
|||||||
{
|
{
|
||||||
id: 'buelstedt',
|
id: 'buelstedt',
|
||||||
name: 'E-TIB Ingenieurgesellschaft mbH',
|
name: 'E-TIB Ingenieurgesellschaft mbH',
|
||||||
shortName: 'Bülstedt (Ingenieurgesellschaft)',
|
shortName: 'Standort Niedersachsen',
|
||||||
type: 'branch',
|
type: 'branch',
|
||||||
description: {
|
description: {
|
||||||
de: 'Unsere Ingenieurgesellschaft in Bülstedt ist spezialisiert auf die hochpräzise Planung, Vermessung und Projektierung von Energie- und Telekommunikationsnetzen. Hier verschmelzen technische Expertise und modernes Datenmanagement.',
|
de: 'Unsere Ingenieurgesellschaft in Bülstedt ist spezialisiert auf die hochpräzise Planung, Vermessung und Projektierung von Energie- und Telekommunikationsnetzen. Hier verschmelzen technische Expertise und modernes Datenmanagement.',
|
||||||
|
|||||||
31
make_contact_sheet.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import os
|
||||||
|
import math
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
folder = 'public/assets/photos'
|
||||||
|
files = [f for f in os.listdir(folder) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]
|
||||||
|
files.sort()
|
||||||
|
|
||||||
|
thumb_w, thumb_h = 300, 300
|
||||||
|
cols = 8
|
||||||
|
rows = math.ceil(len(files) / cols)
|
||||||
|
|
||||||
|
contact_sheet = Image.new('RGB', (cols * thumb_w, rows * thumb_h), (255, 255, 255))
|
||||||
|
draw = ImageDraw.Draw(contact_sheet)
|
||||||
|
|
||||||
|
for i, f in enumerate(files):
|
||||||
|
path = os.path.join(folder, f)
|
||||||
|
try:
|
||||||
|
img = Image.open(path)
|
||||||
|
img.thumbnail((thumb_w, thumb_h - 40))
|
||||||
|
x = (i % cols) * thumb_w
|
||||||
|
y = (i // cols) * thumb_h
|
||||||
|
img_x = x + (thumb_w - img.width) // 2
|
||||||
|
img_y = y + (thumb_h - 40 - img.height) // 2
|
||||||
|
contact_sheet.paste(img, (img_x, img_y))
|
||||||
|
draw.text((x + 10, y + thumb_h - 30), f, fill=(0,0,0))
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
contact_sheet.save('contact_sheet.jpg')
|
||||||
|
print("Saved contact_sheet.jpg")
|
||||||
@@ -15,6 +15,11 @@ const nextConfig = {
|
|||||||
pagesBufferLength: 2,
|
pagesBufferLength: 2,
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
|
optimizeCss: true,
|
||||||
|
staleTimes: {
|
||||||
|
dynamic: 0,
|
||||||
|
static: 30,
|
||||||
|
},
|
||||||
optimizePackageImports: [
|
optimizePackageImports: [
|
||||||
'lucide-react',
|
'lucide-react',
|
||||||
'framer-motion',
|
'framer-motion',
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.2.13-rc.46",
|
"version": "2.2.37",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
3
proxy.ts
@@ -9,8 +9,9 @@ export default createMiddleware({
|
|||||||
export const config = {
|
export const config = {
|
||||||
// Match all pathnames except for
|
// Match all pathnames except for
|
||||||
// - /api (API routes)
|
// - /api (API routes)
|
||||||
|
// - /stats (Analytics proxy)
|
||||||
// - /_next (Next.js internals)
|
// - /_next (Next.js internals)
|
||||||
// - /_static (inside /public)
|
// - /_static (inside /public)
|
||||||
// - all root files inside /public (e.g. /favicon.ico)
|
// - all root files inside /public (e.g. /favicon.ico)
|
||||||
matcher: ['/((?!api|_next|assets|_static|_vercel|[\\w-]+\\.\\w+).*)']
|
matcher: ['/((?!api|stats|_next|assets|_static|_vercel|[\\w-]+\\.\\w+).*)']
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
public/assets/photos/2025-09-23_12.11.43.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 11 MiB After Width: | Height: | Size: 1007 KiB |
BIN
public/assets/photos/2025-09-23_12.12.37.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 777 KiB |
BIN
public/assets/photos/DJI_0037.JPG
Executable file → Normal file
|
Before Width: | Height: | Size: 9.3 MiB After Width: | Height: | Size: 522 KiB |
BIN
public/assets/photos/DJI_0048.JPG
Executable file → Normal file
|
Before Width: | Height: | Size: 7.8 MiB After Width: | Height: | Size: 412 KiB |
BIN
public/assets/photos/DJI_0243.JPG
Executable file → Normal file
|
Before Width: | Height: | Size: 8.6 MiB After Width: | Height: | Size: 488 KiB |
BIN
public/assets/photos/DJI_0301.JPG
Executable file → Normal file
|
Before Width: | Height: | Size: 9.8 MiB After Width: | Height: | Size: 574 KiB |
BIN
public/assets/photos/DSC00373.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 304 KiB |
BIN
public/assets/photos/DSC00375.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 304 KiB |
BIN
public/assets/photos/DSC00388.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 342 KiB |
BIN
public/assets/photos/DSC00400.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 322 KiB |
BIN
public/assets/photos/DSC00430.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 5.5 MiB After Width: | Height: | Size: 540 KiB |
BIN
public/assets/photos/DSC00440.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 358 KiB |
BIN
public/assets/photos/DSC00452.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 6.5 MiB After Width: | Height: | Size: 618 KiB |
BIN
public/assets/photos/DSC02028.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 314 KiB |
BIN
public/assets/photos/DSC02057.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 4.7 MiB After Width: | Height: | Size: 345 KiB |
BIN
public/assets/photos/DSC02069.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 9.0 MiB After Width: | Height: | Size: 597 KiB |
BIN
public/assets/photos/DSC02083.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 7.2 MiB After Width: | Height: | Size: 493 KiB |
BIN
public/assets/photos/DSC02089.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 371 KiB |
BIN
public/assets/photos/DSC02114.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 240 KiB |
BIN
public/assets/photos/DSC02135.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 325 KiB |
BIN
public/assets/photos/DSC02150.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 8.7 MiB After Width: | Height: | Size: 629 KiB |
BIN
public/assets/photos/DSC02152.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 9.4 MiB After Width: | Height: | Size: 659 KiB |
BIN
public/assets/photos/DSC02156.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 9.3 MiB After Width: | Height: | Size: 664 KiB |
BIN
public/assets/photos/DSC02164.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 9.2 MiB After Width: | Height: | Size: 648 KiB |
BIN
public/assets/photos/DSCF8687.JPG
Executable file → Normal file
|
Before Width: | Height: | Size: 8.2 MiB After Width: | Height: | Size: 442 KiB |