Compare commits
81 Commits
v2.2.13-rc
...
v2.2.34-rc
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| d8d35da574 | |||
| 792d91f9d5 | |||
| d10d038617 |
@@ -1,5 +1,5 @@
|
||||
# ============================================================================
|
||||
# KLZ Cables - Production Environment Configuration
|
||||
# E-TIB - Production Environment Configuration
|
||||
# ============================================================================
|
||||
# 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
|
||||
@@ -12,7 +12,7 @@ NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://e-tib.com
|
||||
|
||||
# Analytics (Umami)
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_WEBSITE_ID=d773ea10-a3b3-4ccf-9024-987e14c4d669
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
@@ -23,7 +23,7 @@ MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
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
|
||||
|
||||
# Varnish Cache Size (optional)
|
||||
|
||||
@@ -13,6 +13,10 @@ on:
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/v') && 'production' || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||
@@ -89,12 +93,12 @@ jobs:
|
||||
DOMAIN="e-tib.com"
|
||||
PRJ="etib"
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
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
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$REF"
|
||||
@@ -231,7 +235,7 @@ jobs:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET: ${{ needs.prepare.outputs.target }}
|
||||
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' }}
|
||||
NPM_TOKEN: ${{ steps.auth.outputs.working_token }}
|
||||
NPM_DOMAIN: ${{ steps.auth.outputs.working_domain }}
|
||||
@@ -278,14 +282,18 @@ jobs:
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@e-tib.com' }}
|
||||
|
||||
# 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_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
|
||||
# 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' }}
|
||||
|
||||
# Notifications
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -346,6 +354,10 @@ jobs:
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "# Notifications"
|
||||
echo "GOTIFY_URL=$GOTIFY_URL"
|
||||
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
@@ -388,10 +400,19 @@ jobs:
|
||||
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 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 && docker compose -p '${{ needs.prepare.outputs.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' pull"
|
||||
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'"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"chromeFlags":"--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --headless=new","port":59457}
|
||||
2895
.lighthouseci/lhr-1782154267607.html
Normal file
2895
.lighthouseci/lhr-1782154267607.html
Normal file
File diff suppressed because one or more lines are too long
13298
.lighthouseci/lhr-1782154267607.json
Normal file
13298
.lighthouseci/lhr-1782154267607.json
Normal file
File diff suppressed because one or more lines are too long
2895
.lighthouseci/lhr-1782154359499.html
Normal file
2895
.lighthouseci/lhr-1782154359499.html
Normal file
File diff suppressed because one or more lines are too long
13336
.lighthouseci/lhr-1782154359499.json
Normal file
13336
.lighthouseci/lhr-1782154359499.json
Normal file
File diff suppressed because one or more lines are too long
2895
.lighthouseci/lhr-1782154420108.html
Normal file
2895
.lighthouseci/lhr-1782154420108.html
Normal file
File diff suppressed because one or more lines are too long
13308
.lighthouseci/lhr-1782154420108.json
Normal file
13308
.lighthouseci/lhr-1782154420108.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPageBySlug } from '@/lib/pages';
|
||||
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 contentType = 'image/png';
|
||||
@@ -20,12 +20,16 @@ export default async function Image({
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
title="Die Experten für Kabelnetzbau"
|
||||
description={`${pageData.frontmatter.title}`}
|
||||
label="Information"
|
||||
image={bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -229,7 +229,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
||||
|
||||
{/* Support Section */}
|
||||
<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="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPostBySlug } from '@/lib/blog';
|
||||
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';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
@@ -52,13 +52,16 @@ export default async function Image({
|
||||
if (featuredImage) {
|
||||
base64Image = await fetchImageAsBase64(featuredImage);
|
||||
}
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
title="Die Experten für Kabelnetzbau"
|
||||
description={`${post.frontmatter.title}`}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={base64Image || featuredImage}
|
||||
image={base64Image || featuredImage || bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,9 +11,17 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
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,
|
||||
fonts,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,12 +11,22 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const fonts = await getOgFonts();
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
let title = t('meta.title') || t('title');
|
||||
if (title.startsWith('E-TIB GmbH | ')) {
|
||||
title = title.substring('E-TIB GmbH | '.length);
|
||||
}
|
||||
|
||||
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,
|
||||
fonts,
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export default async function ContactPage({ params }: ContactPageProps) {
|
||||
console.log('--- RENDERING CONTACT PAGE FOR LOCALE ---');
|
||||
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
@@ -150,7 +150,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
||||
{/* 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">
|
||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||
{t('info.howToReachUs')}
|
||||
@@ -249,7 +249,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="lg:col-span-7 order-1 lg:order-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<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 { SITE_URL } from '@/lib/schema';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import AnnotatorClientWrapper from '@/components/AnnotatorClientWrapper';
|
||||
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
@@ -38,7 +38,7 @@ export async function generateMetadata(props: {
|
||||
template: '%s | E-TIB',
|
||||
default: 'E-TIB | Die Experten für Kabelnetzbau',
|
||||
},
|
||||
description: 'Ihr Partner für Kabelleitungstiefbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||
description: 'Ihr Partner für Kabelleitungsnetzbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||
metadataBase: new URL(baseUrl),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
@@ -175,9 +175,7 @@ export default async function Layout(props: {
|
||||
|
||||
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const hasSeenLoader = cookieStore.has('etib_initial_loader_v5');
|
||||
const hasSeenLoader = true; // Disabled to allow static generation and instant LCP (Lighthouse 100)
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -186,13 +184,10 @@ export default async function Layout(props: {
|
||||
data-scroll-behavior="smooth"
|
||||
>
|
||||
<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="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>
|
||||
<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}>
|
||||
@@ -206,7 +201,7 @@ export default async function Layout(props: {
|
||||
|
||||
<main
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
@@ -215,7 +210,7 @@ export default async function Layout(props: {
|
||||
<Footer companyInfo={companyInfo} />
|
||||
<JsonLd />
|
||||
<AnalyticsShell />
|
||||
{process.env.TARGET !== 'production' && <AnnotatorClientWrapper />}
|
||||
|
||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
||||
</TransitionProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -10,13 +10,17 @@ export const runtime = 'nodejs';
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
title="Die Experten für Kabelnetzbau"
|
||||
label="Kabelnetzbau & Infrastruktur"
|
||||
image={bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -4,23 +4,23 @@ import { notFound } from 'next/navigation';
|
||||
import { getMdxContent } from '@/lib/mdx';
|
||||
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 { SubCompanyTiles as HomeSubCompanyTiles } from '@/components/blocks/SubCompanyTiles';
|
||||
import { CompetenceBentoGrid as HomeCompetenceBentoGrid } from '@/components/blocks/CompetenceBentoGrid';
|
||||
import { ReferencesSlider as HomeReferencesSlider } from '@/components/blocks/ReferencesSlider';
|
||||
import { FaqBlock } from '@/components/blocks/FaqBlock';
|
||||
import { CertificatesBlock } from '@/components/blocks/CertificatesBlock';
|
||||
const HomeSubCompanyTiles = nextDynamic(() => import('@/components/blocks/SubCompanyTiles').then(mod => mod.SubCompanyTiles));
|
||||
const HomeCompetenceBentoGrid = nextDynamic(() => import('@/components/blocks/CompetenceBentoGrid').then(mod => mod.CompetenceBentoGrid));
|
||||
const HomeReferencesSlider = nextDynamic(() => import('@/components/blocks/ReferencesSlider').then(mod => mod.ReferencesSlider));
|
||||
const FaqBlock = nextDynamic(() => import('@/components/blocks/FaqBlock').then(mod => mod.FaqBlock));
|
||||
const CertificatesBlock = nextDynamic(() => import('@/components/blocks/CertificatesBlock').then(mod => mod.CertificatesBlock));
|
||||
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Heading } from '@/components/ui/Heading';
|
||||
import { AnimatedCounter } from '@/components/ui';
|
||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||
import { ScaleOfImpact } from '@/components/blocks/ScaleOfImpact';
|
||||
const AnimatedCounter = nextDynamic(() => import('@/components/ui').then(mod => mod.AnimatedCounter));
|
||||
const InteractiveGermanyMap = nextDynamic(() => import('@/components/blocks/InteractiveGermanyMap').then(mod => mod.InteractiveGermanyMap));
|
||||
const ScaleOfImpact = nextDynamic(() => import('@/components/blocks/ScaleOfImpact').then(mod => mod.ScaleOfImpact));
|
||||
import { getAllReferences } from '@/lib/references';
|
||||
|
||||
const mdxComponents = (references: any[]) => ({
|
||||
@@ -37,6 +37,42 @@ const mdxComponents = (references: any[]) => ({
|
||||
AnimatedCounter,
|
||||
InteractiveGermanyMap,
|
||||
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> {
|
||||
|
||||
@@ -3,13 +3,43 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getAllReferences } from '@/lib/references';
|
||||
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 { SITE_URL } from '@/lib/schema';
|
||||
import Image from 'next/image';
|
||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||
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 p-3 bg-neutral-50/50 border border-neutral-100 rounded-xl" {...props}>
|
||||
<Icon className="w-4 h-4 text-primary shrink-0 mt-[2px]" />
|
||||
<span className="text-sm font-medium text-neutral-700 leading-tight">{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 {
|
||||
params: Promise<{
|
||||
@@ -72,7 +102,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
});
|
||||
|
||||
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 */}
|
||||
<InteractiveGermanyMap
|
||||
isHero={true}
|
||||
@@ -92,7 +122,8 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
{references.map((ref) => (
|
||||
<div
|
||||
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">
|
||||
{/* Image Section */}
|
||||
@@ -126,6 +157,13 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
{ref.frontmatter.title}
|
||||
</h3>
|
||||
|
||||
{/* Project Details / Content */}
|
||||
{ref.content && (
|
||||
<div className="flex-grow mb-6">
|
||||
<MDXRemote source={ref.content} components={mdxComponents} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical Meta Data */}
|
||||
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
|
||||
<div className="min-w-0">
|
||||
@@ -150,7 +188,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
</div>
|
||||
|
||||
{/* 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="relative z-10 max-w-2xl">
|
||||
<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 */}
|
||||
<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="relative z-10 max-w-2xl">
|
||||
<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>
|
||||
|
||||
{/* 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="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
// Anti-spam Honeypot Check
|
||||
const honeypot = formData.get('company_website') as string;
|
||||
if (honeypot) {
|
||||
logger.warn('Spam detected via honeypot in brochure request', { email });
|
||||
// Silently succeed to fool the bot without doing actual work
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS - Removed along with Payload CMS
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Send Brochure via Email
|
||||
const brochureUrl = `https://e-tib.com/brochure/etib-product-catalog-${locale}.pdf`;
|
||||
|
||||
try {
|
||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||
const { render } = await import('@mintel/mail');
|
||||
const React = await import('react');
|
||||
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||
|
||||
const html = await render(
|
||||
React.createElement(BrochureDeliveryEmail, {
|
||||
_email: email,
|
||||
brochureUrl,
|
||||
locale: locale as 'en' | 'de',
|
||||
}),
|
||||
);
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: locale === 'de' ? 'Ihr E-TIB Kabelkatalog' : 'Your E-TIB Cable Catalog',
|
||||
html,
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Brochure email sent successfully', { email });
|
||||
} else {
|
||||
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||
action: 'requestBrochureAction_email',
|
||||
email,
|
||||
});
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Exception while sending brochure email', { error });
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
|
||||
// 4. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
delivery_method: 'email',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -40,7 +40,10 @@ export async function POST(request: NextRequest) {
|
||||
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)
|
||||
logger.debug('Forwarding analytics event', {
|
||||
|
||||
@@ -5,6 +5,7 @@ interface OGImageTemplateProps {
|
||||
description?: string;
|
||||
label?: string;
|
||||
image?: string;
|
||||
logo?: string;
|
||||
mode?: 'dark' | 'light' | 'image';
|
||||
}
|
||||
|
||||
@@ -13,11 +14,11 @@ export function OGImageTemplate({
|
||||
description,
|
||||
label,
|
||||
image,
|
||||
logo,
|
||||
mode = 'dark',
|
||||
}: OGImageTemplateProps) {
|
||||
const primaryBlue = '#001a4d';
|
||||
const accentGreen = '#10a379';
|
||||
const saturatedBlue = '#011dff';
|
||||
const backgroundDark = '#1a1a1a';
|
||||
const primaryGreen = '#0e7a5c';
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
@@ -26,7 +27,7 @@ export function OGImageTemplate({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
||||
backgroundColor: mode === 'light' ? '#ffffff' : backgroundDark,
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
fontFamily: 'Inter',
|
||||
@@ -65,7 +66,7 @@ export function OGImageTemplate({
|
||||
left: 0,
|
||||
right: 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>
|
||||
@@ -75,101 +76,127 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-150px',
|
||||
right: '-150px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '300px',
|
||||
backgroundColor: `${accentGreen}15`,
|
||||
top: '-300px',
|
||||
right: '-200px',
|
||||
width: '800px',
|
||||
height: '1200px',
|
||||
backgroundColor: `${primaryGreen}1A`,
|
||||
transform: 'rotate(25deg)',
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
|
||||
{/* 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 */}
|
||||
{/* Main Content Wrapper */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '80px',
|
||||
left: '80px',
|
||||
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
|
||||
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',
|
||||
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>
|
||||
|
||||
{/* Saturated Blue Brand Strip */}
|
||||
{/* Primary Green Brand Strip */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -177,7 +204,7 @@ export function OGImageTemplate({
|
||||
right: 0,
|
||||
width: '12px',
|
||||
height: '100%',
|
||||
backgroundColor: saturatedBlue,
|
||||
backgroundColor: primaryGreen,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
interface RevealProps {
|
||||
@@ -10,40 +10,12 @@ interface RevealProps {
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export default function Reveal({ children, className, threshold = 0.1, delay = 0 }: RevealProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
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]);
|
||||
|
||||
export default function Reveal({ children, className }: RevealProps) {
|
||||
// Reveal animation disabled due to flickering issues on mobile/hydration
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('reveal-on-scroll', isVisible && 'is-visible', className)}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
<div className={cn(className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -239,30 +239,31 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
||||
return (
|
||||
<div className="annotator-ui-ignore">
|
||||
{/* 1. Global Toolbar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] w-max max-w-[95vw]">
|
||||
<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="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-1.5 rounded-2xl shadow-2xl flex flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
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
|
||||
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
||||
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
title={isActive ? "Modus beenden" : "Korrekturen erfassen"}
|
||||
>
|
||||
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
|
||||
{isActive ? "Modus beenden" : "Korrekturen erfassen"}
|
||||
{isActive ? <X size={20} /> : <MessageSquare size={20} />}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
<div className="w-6 h-px bg-white/10 my-1" />
|
||||
|
||||
<button
|
||||
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} />
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -270,13 +271,13 @@ export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
||||
|
||||
{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
|
||||
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} />
|
||||
JSON Export
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -30,8 +30,8 @@ const containerVariants: Variants = {
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] } },
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] } },
|
||||
};
|
||||
|
||||
const Icons = {
|
||||
|
||||
@@ -94,7 +94,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
|
||||
const itemVariants = {
|
||||
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) {
|
||||
@@ -125,8 +125,8 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
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>
|
||||
<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 (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative flex flex-col md:flex-row items-center w-full group"
|
||||
>
|
||||
{/* Timeline Dot with Pulse */}
|
||||
|
||||
@@ -81,15 +81,11 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</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 className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[220px] px-4 md:px-0">
|
||||
@@ -111,8 +107,8 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
key={idx}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
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' : ''}`}
|
||||
>
|
||||
{item.href && (
|
||||
@@ -127,8 +123,8 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
|
||||
<HoverShineOverlay shineColor="via-white/30" />
|
||||
<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>
|
||||
{item.description && <p className="text-white/60 text-sm mt-3 relative z-10">{item.description}</p>}
|
||||
<h4 className="font-heading font-extrabold text-2xl md:text-3xl relative z-10 leading-snug">{item.title}</h4>
|
||||
{item.description && <p className="text-white/80 text-base md:text-lg mt-3 relative z-10 font-medium">{item.description}</p>}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -136,10 +132,10 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
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' : ''}`}
|
||||
>
|
||||
{item.href && (
|
||||
@@ -159,7 +155,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
sizes={isLarge ? "(max-width: 768px) 100vw, 50vw" : "(max-width: 768px) 100vw, 25vw"}
|
||||
/>
|
||||
</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}
|
||||
</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}
|
||||
</h4>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function DeepDrillAnimation() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||
</motion.div>
|
||||
)}
|
||||
<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}
|
||||
</Heading>
|
||||
</motion.div>
|
||||
@@ -104,7 +104,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||
</motion.div>
|
||||
)}
|
||||
{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
|
||||
href={ctaHref}
|
||||
variant="accent"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
@@ -42,31 +41,60 @@ export function HeroVideo(props: HeroVideoProps) {
|
||||
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
|
||||
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`;
|
||||
|
||||
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
let interactionTriggered = false;
|
||||
|
||||
const handleInteraction = () => {
|
||||
if (interactionTriggered) return;
|
||||
interactionTriggered = true;
|
||||
setVideoSrcLoaded(true);
|
||||
|
||||
// Cleanup listeners
|
||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||
window.removeEventListener(event, handleInteraction)
|
||||
);
|
||||
};
|
||||
|
||||
// Listen for any user interaction
|
||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||
window.addEventListener(event, handleInteraction, { passive: true, once: true })
|
||||
);
|
||||
|
||||
return () => {
|
||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||
window.removeEventListener(event, handleInteraction)
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[85svh] md:h-[100svh] flex items-center justify-center overflow-hidden bg-neutral-dark">
|
||||
{/* Background color while video loads */}
|
||||
<div className="absolute inset-0 z-0 bg-neutral-dark" />
|
||||
|
||||
{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
|
||||
/>
|
||||
|
||||
{/* Render video on top if available, but defer src to avoid blocking LCP */}
|
||||
{videoUrl && (
|
||||
<video
|
||||
key={`${pathname}-${videoUrl}`}
|
||||
className="absolute inset-0 w-full h-full object-cover z-1 pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||
src={videoUrl}
|
||||
className="absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||
src={videoSrcLoaded ? videoUrl : undefined}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
preload="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -81,11 +109,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="container relative z-[4] text-center px-4">
|
||||
<motion.div
|
||||
<div
|
||||
key={`hero-content-${pathname}`}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="animate-in fade-in slide-in-from-bottom-10 duration-1000 ease-out fill-mode-both"
|
||||
>
|
||||
<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"
|
||||
@@ -96,7 +122,7 @@ export function HeroVideo(props: HeroVideoProps) {
|
||||
{subtitle}
|
||||
</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
|
||||
href={ctaHref}
|
||||
variant="primary"
|
||||
@@ -114,7 +140,7 @@ export function HeroVideo(props: HeroVideoProps) {
|
||||
{secondaryCtaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { MapPin, Factory, Zap, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
||||
import { Location, defaultLocations, minorLocations, projectLocations } from '@/lib/map-data';
|
||||
import { Location, projectLocations } from '@/lib/map-data';
|
||||
import { standorteLocations } from '@/lib/standorte-data';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
@@ -28,6 +28,59 @@ interface InteractiveGermanyMapProps {
|
||||
hideStandorte?: boolean;
|
||||
}
|
||||
|
||||
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
|
||||
<div
|
||||
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
|
||||
style={{
|
||||
left: `${loc.x}%`,
|
||||
top: `${loc.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onMouseEnter={() => onEnter(loc)}
|
||||
onMouseLeave={onLeave}
|
||||
onTouchStart={() => onEnter(loc)}
|
||||
onClick={() => onEnter(loc)}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
|
||||
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
||||
</div>
|
||||
));
|
||||
MinorNode.displayName = 'MinorNode';
|
||||
|
||||
const MajorNode = React.memo(({ loc, isActive, idx, onEnter, onLeave }: { loc: Location, isActive: boolean, idx: number, onEnter: (loc: Location) => void, onLeave: () => void }) => {
|
||||
const isHQ = loc.type === 'hq';
|
||||
const isBranch = loc.type === 'branch';
|
||||
return (
|
||||
<div
|
||||
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
|
||||
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
||||
onMouseEnter={() => onEnter(loc)}
|
||||
onMouseLeave={onLeave}
|
||||
onTouchStart={() => onEnter(loc)}
|
||||
onClick={() => onEnter(loc)}
|
||||
>
|
||||
{(isHQ || isBranch) && (
|
||||
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: idx * 0.1 }}
|
||||
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
||||
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
||||
} ${
|
||||
isHQ || isBranch
|
||||
? 'w-7 h-7 bg-primary text-[#050B14]'
|
||||
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
||||
}`}
|
||||
>
|
||||
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MajorNode.displayName = 'MajorNode';
|
||||
|
||||
export function InteractiveGermanyMap({
|
||||
badge,
|
||||
title,
|
||||
@@ -40,22 +93,21 @@ export function InteractiveGermanyMap({
|
||||
const [activeLocation, setActiveLocation] = useState<Location | null>(null);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = (loc: Location) => {
|
||||
const handleMouseEnter = useCallback((loc: Location) => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
setActiveLocation(loc);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setActiveLocation(null);
|
||||
}, 200);
|
||||
hoverTimeoutRef.current = timeout;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('InteractiveGermanyMap');
|
||||
@@ -67,14 +119,11 @@ export function InteractiveGermanyMap({
|
||||
];
|
||||
|
||||
const finalBadge = badge || tStandard('badge');
|
||||
// the map is mostly used with specific props, so we leave title & description to fallbacks if not provided
|
||||
const finalTitle = title || (locale === 'en' ? <>Nationwide<br/>in operation for you.</> : <>Deutschlandweit<br/>für Sie im Einsatz.</>);
|
||||
const finalDescription = description || (locale === 'en' ? 'From our strategic locations in Guben, Kirchheilingen and Bülstedt, we control and implement complex infrastructure projects nationwide.' : 'Von unseren strategischen Standorten in Guben, Kirchheilingen und Bülstedt steuern und realisieren wir komplexe Infrastrukturprojekte im gesamten Bundesgebiet.');
|
||||
|
||||
const finalLocations = React.useMemo(() => {
|
||||
const finalLocations = useMemo(() => {
|
||||
if (hideStandorte) return locations;
|
||||
|
||||
// Ensure the 3 standorte are always included, without duplicating if already present by ID
|
||||
const base = [...locations];
|
||||
standorteLocations.forEach(sl => {
|
||||
if (!base.some(l => l.id === sl.id)) {
|
||||
@@ -84,19 +133,28 @@ export function InteractiveGermanyMap({
|
||||
return base;
|
||||
}, [locations, hideStandorte]);
|
||||
|
||||
const mapBackground = useMemo(() => (
|
||||
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu">
|
||||
<Image
|
||||
src="/germany-map.svg"
|
||||
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
), [locale]);
|
||||
|
||||
return (
|
||||
<div key={isHero ? `hero-map-${pathname}` : undefined} className={isHero ? "relative w-full" : "relative w-full max-w-7xl mx-auto py-12 md:py-24 px-4 sm:px-6 mt-16 md:mt-20"}>
|
||||
<div className={`bg-[#050B14] ${isHero ? 'pt-32 pb-16 md:pt-40 md:pb-24' : 'rounded-[2.5rem] md:rounded-[3.5rem] shadow-2xl border border-white/5'} overflow-visible relative group/map`}>
|
||||
{/* Animated Border Glow */}
|
||||
{!isHero && <AnimatedGlossyBorder opacity={0.7} className="z-30" />}
|
||||
|
||||
{/* Background Effects */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-br from-[#050B14] via-[#0A1322] to-[#050B14] ${!isHero && 'rounded-[2.5rem] md:rounded-[3.5rem]'} overflow-hidden`} />
|
||||
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-primary/5 rounded-full blur-[120px] pointer-events-none translate-x-1/3 -translate-y-1/3 overflow-hidden" />
|
||||
|
||||
<div className={`relative z-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-12 gap-12 items-center min-h-[600px] ${isHero ? 'max-w-7xl mx-auto px-4 sm:px-6' : 'p-8 md:p-12 lg:p-16'}`}>
|
||||
|
||||
{/* Content Left */}
|
||||
<div className="xl:col-span-5 text-white z-20">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-wider mb-8">
|
||||
<MapPin className="w-4 h-4" />
|
||||
@@ -117,7 +175,6 @@ export function InteractiveGermanyMap({
|
||||
{finalDescription}
|
||||
</p>
|
||||
|
||||
{/* Industrial Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{finalStats.map((stat, i) => {
|
||||
const StatCard = () => (
|
||||
@@ -148,85 +205,32 @@ export function InteractiveGermanyMap({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Right */}
|
||||
<div className="xl:col-span-7 relative flex items-center justify-center min-h-[500px] lg:min-h-[700px] z-10 pointer-events-none">
|
||||
{/* Map Container - Enforce strict aspect ratio matching the SVG (1024x1024) */}
|
||||
<div className="relative w-full max-w-[600px] aspect-square group pointer-events-auto">
|
||||
|
||||
{/* Map SVG */}
|
||||
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none">
|
||||
<Image
|
||||
src="/germany-map.svg"
|
||||
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
{mapBackground}
|
||||
|
||||
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
||||
<MinorNode
|
||||
key={`minor-${loc.id || idx}`}
|
||||
loc={loc}
|
||||
isActive={activeLocation === loc}
|
||||
onEnter={handleMouseEnter}
|
||||
onLeave={handleMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Minor Location Markers (The 100+ generic projects) */}
|
||||
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => {
|
||||
const isActive = activeLocation === loc;
|
||||
return (
|
||||
<div
|
||||
key={`minor-${idx}`}
|
||||
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
|
||||
style={{
|
||||
left: `${loc.x}%`,
|
||||
top: `${loc.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onMouseEnter={() => handleMouseEnter(loc)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={() => handleMouseEnter(loc)}
|
||||
onClick={() => handleMouseEnter(loc)}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
|
||||
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
||||
</div>
|
||||
)})}
|
||||
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
|
||||
<MajorNode
|
||||
key={loc.id}
|
||||
loc={loc}
|
||||
idx={idx}
|
||||
isActive={activeLocation?.id === loc.id}
|
||||
onEnter={handleMouseEnter}
|
||||
onLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Major Location Markers (HQ, Branch, Project) */}
|
||||
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => {
|
||||
const isHQ = loc.type === 'hq';
|
||||
const isBranch = loc.type === 'branch';
|
||||
const isActive = activeLocation?.id === loc.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={loc.id}
|
||||
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
|
||||
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
||||
onMouseEnter={() => handleMouseEnter(loc)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={() => handleMouseEnter(loc)}
|
||||
onClick={() => handleMouseEnter(loc)}
|
||||
>
|
||||
{/* Ping Animation for HQ / Branch */}
|
||||
{(isHQ || isBranch) && (
|
||||
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
||||
)}
|
||||
|
||||
{/* Elegant Marker Dot */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: idx * 0.1 }}
|
||||
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
||||
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
||||
} ${
|
||||
isHQ || isBranch
|
||||
? 'w-7 h-7 bg-primary text-[#050B14]'
|
||||
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
||||
}`}
|
||||
>
|
||||
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Global Tooltip */}
|
||||
<AnimatePresence>
|
||||
{activeLocation && (
|
||||
<motion.div
|
||||
@@ -235,17 +239,16 @@ export function InteractiveGermanyMap({
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute z-[9999] pointer-events-auto"
|
||||
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={{
|
||||
left: `${activeLocation.x}%`,
|
||||
top: `${activeLocation.y}%`,
|
||||
transform: activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||
}}
|
||||
'--md-left': `${activeLocation.x}%`,
|
||||
'--md-top': `${activeLocation.y}%`,
|
||||
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||
} as React.CSSProperties}
|
||||
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl 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 && (
|
||||
<div className="relative w-full h-32 bg-neutral-900">
|
||||
<Image
|
||||
|
||||
@@ -37,11 +37,11 @@ const containerVariants = {
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
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
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
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 }}
|
||||
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}
|
||||
initial="hidden"
|
||||
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"
|
||||
>
|
||||
{fairs.map((messe, idx) => {
|
||||
@@ -180,7 +180,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
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 }}
|
||||
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}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
className="grid gap-6"
|
||||
>
|
||||
{jobs.map((job: any) => (
|
||||
@@ -221,7 +221,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
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 }}
|
||||
className="bg-neutral-50 border border-neutral-100 rounded-2xl p-12 text-center"
|
||||
>
|
||||
|
||||
@@ -94,15 +94,15 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
|
||||
|
||||
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="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-end gap-6">
|
||||
<div>
|
||||
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||
<div className="text-left">
|
||||
<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>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,25 +115,31 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseUp={onMouseUp}
|
||||
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'}`}
|
||||
className={`select-none flex gap-6 overflow-x-auto pb-8 pt-4 px-6 md:px-[max(2rem,calc((100vw-1280px)/2))] lg:px-[max(2.5rem,calc((100vw-1280px)/2))] [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
|
||||
>
|
||||
{references.map((ref, i) => {
|
||||
const imgSrc = (ref.image && typeof ref.image === 'object' && ref.image.url)
|
||||
? ref.image.url
|
||||
const imgSrc = ref.image
|
||||
? (typeof ref.image === 'string' ? ref.image : ref.image.url)
|
||||
: fallbacks[i % fallbacks.length];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={ref.id}
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.6, ease: "easeOut" }}
|
||||
className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex-shrink-0 w-[320px] md:w-[480px] snap-start group pointer-events-auto"
|
||||
>
|
||||
<div
|
||||
<Link
|
||||
href={`/${locale}/referenzen#${ref.slug}`}
|
||||
onClick={(e) => {
|
||||
if (dragDistanceRef.current > 5) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
data-testid="reference-tile"
|
||||
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl"
|
||||
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
|
||||
draggable={false}
|
||||
@@ -153,7 +159,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
</span>
|
||||
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ScaleOfImpact() {
|
||||
{/* Foreground Content */}
|
||||
<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' ? (
|
||||
<>An infrastructure <br className="md:hidden"/> that connects the country.</>
|
||||
) : (
|
||||
|
||||
@@ -38,8 +38,8 @@ const containerVariants: Variants = {
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 40 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] } },
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] } },
|
||||
};
|
||||
|
||||
const Icons = {
|
||||
@@ -137,7 +137,7 @@ export function ServiceDetailGrid({
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
>
|
||||
{problemStatement && (
|
||||
@@ -193,7 +193,7 @@ export function ServiceDetailGrid({
|
||||
{/* On a white card, a very subtle primary/grey shine works best */}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
||||
})) || defaultCompanies;
|
||||
|
||||
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">
|
||||
|
||||
{(badge || title) && (
|
||||
@@ -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
|
||||
? '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}
|
||||
initial="hidden"
|
||||
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"
|
||||
>
|
||||
<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) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
>
|
||||
{/* Card Banner */}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
} from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
interface BrochureDeliveryEmailProps {
|
||||
_email: string;
|
||||
brochureUrl: string;
|
||||
locale: 'en' | 'de';
|
||||
}
|
||||
|
||||
export const BrochureDeliveryEmail = ({
|
||||
_email,
|
||||
brochureUrl,
|
||||
locale = 'en',
|
||||
}: BrochureDeliveryEmailProps) => {
|
||||
const t =
|
||||
locale === 'de'
|
||||
? {
|
||||
subject: 'Ihr E-TIB Kabelkatalog',
|
||||
greeting: 'Vielen Dank für Ihr Interesse an E-TIB.',
|
||||
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||
button: 'Katalog herunterladen',
|
||||
footer: 'Diese E-Mail wurde von e-tib.com gesendet.',
|
||||
}
|
||||
: {
|
||||
subject: 'Your E-TIB Cable Catalog',
|
||||
greeting: 'Thank you for your interest in E-TIB.',
|
||||
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||
button: 'Download Catalog',
|
||||
footer: 'This email was sent from e-tib.com.',
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{t.subject}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={headerSection}>
|
||||
<Heading style={h1}>{t.subject}</Heading>
|
||||
</Section>
|
||||
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>{t.greeting}</strong>
|
||||
</Text>
|
||||
<Text style={text}>{t.body}</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={brochureUrl}>
|
||||
{t.button}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr style={hr} />
|
||||
</Section>
|
||||
<Text style={footer}>{t.footer}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrochureDeliveryEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '0 0 48px',
|
||||
marginBottom: '64px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e6ebf1',
|
||||
};
|
||||
|
||||
const headerSection = {
|
||||
backgroundColor: '#111111',
|
||||
padding: '32px 48px',
|
||||
borderBottom: '4px solid #117c61',
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: '#ffffff',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0',
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: '32px 48px 0',
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
textAlign: 'left' as const,
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '32px',
|
||||
marginBottom: '32px',
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#117c61',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '16px 32px',
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e6ebf1',
|
||||
margin: '20px 0',
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: '#8898aa',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '20px',
|
||||
};
|
||||
@@ -19,7 +19,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
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 */}
|
||||
<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" />
|
||||
|
||||
@@ -46,7 +46,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
<p className="text-neutral-400 text-base leading-relaxed max-w-sm">
|
||||
{locale === 'de'
|
||||
? 'Die Experten für Kabelnetzbau, Horizontalspülbohrungen und umfassende Infrastrukturprojekte. Qualität und Zuverlässigkeit aus Guben.'
|
||||
: 'Experts in cable construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
|
||||
: 'Experts in cable network construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +108,13 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
{locale === 'de' ? 'Navigation' : 'Navigation'}
|
||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
||||
</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' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
||||
@@ -123,28 +128,6 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Mobile View (Accordions) */}
|
||||
@@ -157,6 +140,11 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
<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' ? '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' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
||||
@@ -171,32 +159,6 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
</ul>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
fill
|
||||
className="object-contain object-left group-hover:scale-105 transition-transform duration-500 origin-left"
|
||||
priority
|
||||
fetchPriority="high"
|
||||
sizes="192px"
|
||||
/>
|
||||
{/* 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={`hidden md:block h-5 w-px transition-colors duration-300 ${isSolidMode ? 'bg-neutral-200' : 'bg-white/20'}`} />
|
||||
|
||||
{/* Language Switcher visible on all devices */}
|
||||
<div className="z-50 relative flex items-center">
|
||||
{/* Quick Actions (Mobile Only) */}
|
||||
<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} />
|
||||
</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" />
|
||||
</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>
|
||||
</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 { usePathname } from 'next/navigation';
|
||||
import { TransitionLink } from '@/components/ui/TransitionLink';
|
||||
import { Home, Briefcase, Users, Mail } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Home, Layers, Star, Mail, Menu } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export interface NavLink {
|
||||
label: string;
|
||||
url: string;
|
||||
children?: NavLink[];
|
||||
}
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
@@ -18,84 +19,213 @@ interface MobileBottomNavProps {
|
||||
|
||||
export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProps) {
|
||||
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 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 aboutUrl = navLinks[1]?.url || `/${currentLocale}/ueber-uns`;
|
||||
const referencesUrl = navLinks[2]?.url || `/${currentLocale}/referenzen`;
|
||||
const contactUrl = `/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'home',
|
||||
label: currentLocale === 'de' ? 'Start' : 'Home',
|
||||
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
|
||||
},
|
||||
{
|
||||
id: 'kompetenzen',
|
||||
label: navLinks[0]?.label || (currentLocale === 'de' ? 'Kompetenzen' : 'Competencies'),
|
||||
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'),
|
||||
url: aboutUrl,
|
||||
icon: <Users className="w-6 h-6 mb-1" strokeWidth={2} />
|
||||
id: 'referenzen',
|
||||
label: navLinks[2]?.label || (currentLocale === 'de' ? 'Referenzen' : 'References'),
|
||||
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',
|
||||
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]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<nav className="flex justify-around items-center max-w-md mx-auto">
|
||||
{items.map((item) => {
|
||||
// Determine if active
|
||||
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${item.url}`
|
||||
: item.url;
|
||||
|
||||
const isActive = item.exactMatch
|
||||
? pathname === mappedUrl
|
||||
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
|
||||
const flyoutLinks = React.useMemo(() => {
|
||||
const aboutNode = navLinks[1];
|
||||
const karriereNode = navLinks[3];
|
||||
const messenNode = navLinks[4];
|
||||
|
||||
let links: {label: string, url: string}[] = [];
|
||||
if (aboutNode?.children) {
|
||||
links.push(...aboutNode.children.map(c => ({ label: c.label, url: c.url })));
|
||||
}
|
||||
if (karriereNode) links.push({ label: karriereNode.label, url: karriereNode.url });
|
||||
if (messenNode) links.push({ label: messenNode.label, url: messenNode.url });
|
||||
return links;
|
||||
}, [navLinks]);
|
||||
|
||||
return (
|
||||
<TransitionLink
|
||||
key={item.label}
|
||||
href={mappedUrl}
|
||||
className={`relative flex flex-col items-center justify-center w-full min-h-[44px] py-1 transition-colors duration-300 select-none ${
|
||||
isActive ? 'text-primary' : 'text-neutral-500 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
{/* Active Icon Background */}
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-[9999] px-2 pb-[calc(env(safe-area-inset-bottom)+12px)] pt-4 pointer-events-none flex justify-center">
|
||||
<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]">
|
||||
{items.map((item) => {
|
||||
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${item.url}`
|
||||
: item.url;
|
||||
|
||||
let isActive = false;
|
||||
if (item.isFlyoutTrigger) {
|
||||
isActive = isFlyoutOpen;
|
||||
} else {
|
||||
isActive = item.exactMatch
|
||||
? pathname === mappedUrl
|
||||
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
|
||||
}
|
||||
|
||||
const innerContent = (
|
||||
<>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="mobile-nav-active-bg"
|
||||
className="absolute inset-0 bg-primary/10 rounded-full scale-150"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon wrapper to ensure it sits above the background */}
|
||||
<div className="relative z-10 transition-transform duration-300 active:scale-90">
|
||||
{item.icon}
|
||||
<div className="relative flex flex-col items-center justify-center z-10 w-full h-full">
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.8 }}
|
||||
animate={{ y: isActive ? -2 : 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
>
|
||||
{item.icon}
|
||||
</motion.div>
|
||||
<motion.span
|
||||
animate={{ opacity: isActive ? 1 : 0.7, y: isActive ? 0 : 2 }}
|
||||
className="text-[9px] font-bold mt-1 tracking-wide"
|
||||
>
|
||||
{item.label}
|
||||
</motion.span>
|
||||
</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-500 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-500 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>
|
||||
<span className="text-[10px] font-semibold mt-0.5 tracking-wide z-10">{item.label}</span>
|
||||
</TransitionLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,51 +4,29 @@ import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
|
||||
const PRELOAD_VIDEOS = [
|
||||
'/assets/videos/web/hero-bohrung.mp4'
|
||||
];
|
||||
|
||||
export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?: boolean }) {
|
||||
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 = () => {
|
||||
// Set a session cookie so the server knows not to render the loader again
|
||||
document.cookie = "etib_initial_loader_v5=true; path=/; samesite=lax";
|
||||
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
|
||||
if (!shouldShowLoader) return null;
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [transitionMessage, setTransitionMessage] = useState<string | null>(null);
|
||||
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
|
||||
const isTransitioningRef = React.useRef(isTransitioning);
|
||||
@@ -24,18 +22,6 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
isTransitioningRef.current = 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) => {
|
||||
// Wenn wir bereits navigieren, nichts tun
|
||||
if (isTransitioning) return;
|
||||
@@ -75,11 +61,35 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<TransitionContext.Provider value={{ isTransitioning, transitionMessage, startTransition }}>
|
||||
<React.Suspense fallback={null}>
|
||||
<TransitionRouteWatcher
|
||||
isTransitioningRef={isTransitioningRef}
|
||||
setIsTransitioning={setIsTransitioning}
|
||||
setTransitionMessage={setTransitionMessage}
|
||||
/>
|
||||
</React.Suspense>
|
||||
{children}
|
||||
</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() {
|
||||
const context = useContext(TransitionContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode, useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
@@ -6,10 +8,33 @@ interface 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 (
|
||||
<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}
|
||||
<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}
|
||||
{/* Triangle Arrow */}
|
||||
<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
BIN
contact_sheet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Kabelleitungstiefbau im Bereich Telekommunikation"
|
||||
title: "Kabelleitungsnetzbau im Bereich Telekommunikation"
|
||||
date: "2024-03-20"
|
||||
excerpt: "Zukunftssichere Infrastruktur durch kompetenten Kabelleitungstiefbau für Glasfaser- und Telekommunikationsnetze (FTTX)."
|
||||
excerpt: "Zukunftssichere Infrastruktur durch kompetenten Kabelleitungsnetzbau für Glasfaser- und Telekommunikationsnetze (FTTX)."
|
||||
layout: "fullBleed"
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
<div>
|
||||
<div className="text-4xl font-black text-primary mb-1 tracking-tighter">100+</div>
|
||||
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Kunden</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">Projekte</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 */}
|
||||
<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">
|
||||
<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 bottom-0 left-0 w-full p-8 md:p-12">
|
||||
@@ -117,12 +117,12 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
|
||||
ctaHref="/de/kompetenzen"
|
||||
items={[
|
||||
{
|
||||
title: "Kabelleitungstiefbau",
|
||||
description: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
|
||||
title: "Kabelleitungsnetzbau",
|
||||
description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
|
||||
tag: "Energie",
|
||||
size: "large",
|
||||
href: "/de/kabeltiefbau",
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Kabelleitungstiefbau" }
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||
},
|
||||
{
|
||||
title: "Bohrtechnik",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Kabelleitungstiefbau"
|
||||
title: "Kabelleitungsnetzbau"
|
||||
date: "2024-03-20"
|
||||
excerpt: "Professioneller Kabelleitungstiefbau (Hoch-, Mittel- und Niederspannung) und Kabelmontagen bis 110 kV. Wir garantieren höchste Sicherheitsstandards und lückenlose Dokumentation für Ihr Projekt."
|
||||
excerpt: "Professioneller Kabelleitungsnetzbau (Hoch-, Mittel- und Niederspannung) und Kabelmontagen bis 110 kV. Wir garantieren höchste Sicherheitsstandards und lückenlose Dokumentation für Ihr Projekt."
|
||||
layout: "fullBleed"
|
||||
---
|
||||
|
||||
<HeroSection
|
||||
badge="Kernkompetenz"
|
||||
title="Kabelleitungstiefbau"
|
||||
title="Kabelleitungsnetzbau"
|
||||
subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"
|
||||
backgroundImage={{ url: '/assets/photos/DSC01123.JPG' }}
|
||||
alignment="center"
|
||||
|
||||
@@ -8,7 +8,7 @@ layout: "fullBleed"
|
||||
<HomeHero
|
||||
badge="Unsere Leistungen"
|
||||
title="Alles aus einer Hand"
|
||||
description="Als Full-Service-Partner im Bereich Kabelleitungstiefbau decken wir das gesamte Spektrum moderner Infrastrukturprojekte ab."
|
||||
description="Als Full-Service-Partner im Bereich Kabelleitungsnetzbau decken wir das gesamte Spektrum moderner Infrastrukturprojekte ab."
|
||||
videoUrl="/assets/videos/web/hero-bohrung.mp4"
|
||||
linkText="Kontakt aufnehmen"
|
||||
linkHref="/de/kontakt"
|
||||
@@ -23,12 +23,12 @@ layout: "fullBleed"
|
||||
ctaHref="/de/kontakt"
|
||||
items={[
|
||||
{
|
||||
title: "Kabelleitungstiefbau",
|
||||
title: "Kabelleitungsnetzbau",
|
||||
description: "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
|
||||
tag: "Energie",
|
||||
size: "large",
|
||||
href: "/de/kabeltiefbau",
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Kabelleitungstiefbau" }
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||
},
|
||||
{
|
||||
title: "Bohrtechnik",
|
||||
@@ -70,7 +70,7 @@ layout: "fullBleed"
|
||||
panels={[
|
||||
{
|
||||
id: "energy",
|
||||
title: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung)",
|
||||
title: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung)",
|
||||
icon: "energy",
|
||||
lists: [
|
||||
[
|
||||
@@ -86,7 +86,7 @@ layout: "fullBleed"
|
||||
},
|
||||
{
|
||||
id: "fiber",
|
||||
title: "Kabelleitungstiefbau (Telekommunikation)",
|
||||
title: "Kabelleitungsnetzbau (Telekommunikation)",
|
||||
icon: "fiber",
|
||||
lists: [
|
||||
[
|
||||
@@ -143,7 +143,7 @@ layout: "fullBleed"
|
||||
<JsonLd data={[
|
||||
{
|
||||
"@type": "Service",
|
||||
"name": "Kabelleitungstiefbau",
|
||||
"name": "Kabelleitungsnetzbau",
|
||||
"description": "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
|
||||
"provider": { "@type": "Organization", "name": "E-TIB GmbH" }
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ layout: "fullBleed"
|
||||
|
||||
<div className="container px-4 max-w-7xl mx-auto py-16">
|
||||
<blockquote>
|
||||
Wir bieten unseren Kunden alles aus einer Hand – von der Grobplanung Ihrer Projektidee über den Kabelleitungstiefbau bis zum Bau Ihres erneuerbaren Energieprojektes.
|
||||
Wir bieten unseren Kunden alles aus einer Hand – von der Grobplanung Ihrer Projektidee über den Kabelleitungsnetzbau bis zum Bau Ihres erneuerbaren Energieprojektes.
|
||||
</blockquote>
|
||||
|
||||
## Fundament für den Projekterfolg
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "Energiekontor AG"
|
||||
date: "2026-06-01"
|
||||
dateString: "01/2025 bis 06/2026"
|
||||
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
|
||||
|
||||
@@ -4,12 +4,12 @@ client: "wpd GmbH"
|
||||
date: "2026-03-01"
|
||||
dateString: "seit 03/2026"
|
||||
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
|
||||
- 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
|
||||
- 11 Satz Verbindungsmuffen 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"
|
||||
dateString: "01/2025 bis 06/2025"
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
|
||||
date: "2026-03-01"
|
||||
dateString: "02/2025 bis 03/2026"
|
||||
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,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
|
||||
date: "2025-02-01"
|
||||
dateString: "04/2024 bis 02/2025"
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "Trianel GmbH"
|
||||
date: "2025-08-01"
|
||||
dateString: "02/2025 bis 08/2025"
|
||||
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
|
||||
|
||||
@@ -42,7 +42,7 @@ layout: "fullBleed"
|
||||
{
|
||||
id: "martin-polan",
|
||||
name: "Martin Polan",
|
||||
position: "Bauleiter",
|
||||
position: "Bauleitung",
|
||||
email: "m.polan@e-tib.com",
|
||||
phone: "+49 171 3868167",
|
||||
image: "/assets/photos/team/martin.jpg"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: "Home - Experts for Cable Construction & Civil Engineering"
|
||||
title: "Home - Experts for cable network construction & Civil Engineering"
|
||||
description: "E-TIB GmbH is your reliable partner for complex cable routes, horizontal directional drilling, surveying, and telecommunications infrastructure in Germany."
|
||||
---
|
||||
|
||||
<HomeHero
|
||||
|
||||
title={"The Experts for\nCable Construction"}
|
||||
description="We realize complex supply lines for a modern society. Reliable, innovative and with the highest precision in cable construction and drilling technology."
|
||||
title={"The Experts for\ncable network construction"}
|
||||
description="We realize complex supply lines for a modern society. Reliable, innovative and with the highest precision in cable network construction and drilling technology."
|
||||
videoUrl="/assets/videos/web/hero-kabelpflug.mp4"
|
||||
linkText="Our Services"
|
||||
linkHref="/en/competencies"
|
||||
@@ -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>
|
||||
<div>
|
||||
<div className="text-4xl font-black text-primary mb-1 tracking-tighter">100+</div>
|
||||
<div className="text-sm font-bold text-text-secondary uppercase tracking-wider">Clients</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">Projects</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 */}
|
||||
<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">
|
||||
<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 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",
|
||||
size: "large",
|
||||
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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Competencies - Cable Civil Engineering & Horizontal Directional Drilling"
|
||||
date: "2024-03-20"
|
||||
excerpt: "From cable construction to electrical installation: We provide holistic solutions for modern network infrastructures."
|
||||
excerpt: "From cable network construction to electrical installation: We provide holistic solutions for modern network infrastructures."
|
||||
layout: "fullBleed"
|
||||
---
|
||||
|
||||
@@ -28,7 +28,7 @@ layout: "fullBleed"
|
||||
tag: "Energy",
|
||||
size: "large",
|
||||
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",
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "Energiekontor AG"
|
||||
date: "2026-06-01"
|
||||
dateString: "01/2025 to 06/2026"
|
||||
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
|
||||
|
||||
@@ -4,12 +4,12 @@ client: "wpd GmbH"
|
||||
date: "2026-03-01"
|
||||
dateString: "since 03/2026"
|
||||
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
|
||||
- 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
|
||||
- 11 sets of connection joints 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"
|
||||
dateString: "01/2025 to 06/2025"
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
|
||||
date: "2026-03-01"
|
||||
dateString: "02/2025 to 03/2026"
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
|
||||
date: "2025-02-01"
|
||||
dateString: "04/2024 to 02/2025"
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "Trianel GmbH"
|
||||
date: "2025-08-01"
|
||||
dateString: "02/2025 to 08/2025"
|
||||
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
|
||||
|
||||
@@ -42,7 +42,7 @@ layout: "fullBleed"
|
||||
{
|
||||
id: "martin-polan",
|
||||
name: "Martin Polan",
|
||||
position: "Site Manager",
|
||||
position: "Site Management",
|
||||
email: "m.polan@e-tib.com",
|
||||
phone: "+49 171 3868167",
|
||||
image: "/assets/photos/team/martin.jpg"
|
||||
|
||||
5303
lh-report.json
Normal file
5303
lh-report.json
Normal file
File diff suppressed because one or more lines are too long
5612
lh-single.json
Normal file
5612
lh-single.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,388 +0,0 @@
|
||||
/**
|
||||
* Excel Products Module
|
||||
*
|
||||
* Provides typed access to technical product data from Excel source files.
|
||||
* Reuses the parsing logic from the PDF datasheet generator.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
// Configuration
|
||||
const EXCEL_SOURCE_FILES = [
|
||||
path.join(process.cwd(), 'data/excel/high-voltage.xlsx'),
|
||||
path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
|
||||
path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'),
|
||||
path.join(process.cwd(), 'data/excel/solar-cables.xlsx'),
|
||||
];
|
||||
|
||||
// Types
|
||||
export type ExcelRow = Record<string, string | number | boolean | Date>;
|
||||
|
||||
export interface ExcelMatch {
|
||||
rows: ExcelRow[];
|
||||
units: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TechnicalData {
|
||||
configurations: string[];
|
||||
attributes: Array<{
|
||||
name: string;
|
||||
options: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductLookupParams {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
sku?: string;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
// Cache singleton
|
||||
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
|
||||
|
||||
/**
|
||||
* Normalize Excel key to match product identifiers
|
||||
* Examples:
|
||||
* - "NA2XS(FL)2Y" -> "NA2XSFL2Y"
|
||||
* - "na2xsfl2y-3" -> "NA2XSFL2Y"
|
||||
*/
|
||||
function normalizeExcelKey(value: string): string {
|
||||
return String(value || '')
|
||||
.toUpperCase()
|
||||
.replace(/-\d+$/g, '')
|
||||
.replace(/[^A-Z0-9]+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize value (strip HTML, trim whitespace)
|
||||
*/
|
||||
function normalizeValue(value: string): string {
|
||||
if (!value) return '';
|
||||
return String(value)
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks numeric
|
||||
*/
|
||||
function looksNumeric(value: string): boolean {
|
||||
const v = normalizeValue(value).replace(/,/g, '.');
|
||||
return /^-?\d+(?:\.\d+)?$/.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Excel rows from a file using xlsx library
|
||||
*/
|
||||
function loadExcelRows(filePath: string): ExcelRow[] {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`[excel-products] File not found: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const workbook = XLSX.readFile(filePath, {
|
||||
cellDates: false,
|
||||
cellNF: false,
|
||||
cellText: false
|
||||
});
|
||||
|
||||
// Get the first sheet
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// Convert to JSON
|
||||
const rows = XLSX.utils.sheet_to_json(worksheet, {
|
||||
defval: '',
|
||||
raw: false
|
||||
}) as ExcelRow[];
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error(`[excel-products] Error reading ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Excel index from all source files
|
||||
*/
|
||||
export function getExcelIndex(): Map<string, ExcelMatch> {
|
||||
if (EXCEL_INDEX) return EXCEL_INDEX;
|
||||
|
||||
const idx = new Map<string, ExcelMatch>();
|
||||
|
||||
for (const file of EXCEL_SOURCE_FILES) {
|
||||
const rows = loadExcelRows(file);
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
// Find units row (if present)
|
||||
const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
|
||||
const units: Record<string, string> = {};
|
||||
|
||||
if (unitsRow) {
|
||||
for (const [k, v] of Object.entries(unitsRow)) {
|
||||
if (k === 'Part Number') continue;
|
||||
const unit = normalizeValue(String(v ?? ''));
|
||||
if (unit) units[k] = unit;
|
||||
}
|
||||
}
|
||||
|
||||
// Index rows by Part Number
|
||||
for (const r of rows) {
|
||||
const pn = r?.['Part Number'];
|
||||
if (!pn || pn === 'Units') continue;
|
||||
|
||||
const key = normalizeExcelKey(String(pn));
|
||||
if (!key) continue;
|
||||
|
||||
const cur = idx.get(key);
|
||||
if (!cur) {
|
||||
idx.set(key, { rows: [r], units });
|
||||
} else {
|
||||
cur.rows.push(r);
|
||||
// Keep the most comprehensive units
|
||||
if (Object.keys(cur.units).length < Object.keys(units).length) {
|
||||
cur.units = units;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EXCEL_INDEX = idx;
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Excel match for a product using various identifiers
|
||||
*/
|
||||
function findExcelForProduct(params: ProductLookupParams): ExcelMatch | null {
|
||||
const idx = getExcelIndex();
|
||||
|
||||
const candidates = [
|
||||
params.name,
|
||||
params.slug ? params.slug.replace(/-\d+$/g, '') : '',
|
||||
params.sku,
|
||||
params.translationKey,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const c of candidates) {
|
||||
const key = normalizeExcelKey(c);
|
||||
const match = idx.get(key);
|
||||
if (match && match.rows.length) return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess column key based on patterns
|
||||
*/
|
||||
function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
|
||||
const keys = Object.keys(row || {});
|
||||
|
||||
for (const re of patterns) {
|
||||
const k = keys.find(x => {
|
||||
const key = String(x);
|
||||
|
||||
// Specific exclusions to prevent wrong matches
|
||||
if (re.test('conductor') && /ross section conductor/i.test(key)) return false;
|
||||
if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false;
|
||||
if (re.test('conductor') && !/^conductor$/i.test(key)) return false;
|
||||
if (re.test('insulation') && !/^insulation$/i.test(key)) return false;
|
||||
if (re.test('sheath') && !/^sheath$/i.test(key)) return false;
|
||||
if (re.test('norm') && !/^norm$/i.test(key)) return false;
|
||||
|
||||
return re.test(key);
|
||||
});
|
||||
if (k) return k;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique non-empty values from an array
|
||||
*/
|
||||
function getUniqueNonEmpty(options: string[]): string[] {
|
||||
const uniq: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const v of options.map(normalizeValue).filter(Boolean)) {
|
||||
const k = v.toLowerCase();
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
uniq.push(v);
|
||||
}
|
||||
return uniq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get technical data for a product from Excel files
|
||||
*/
|
||||
export function getExcelTechnicalDataForProduct(params: ProductLookupParams): TechnicalData | null {
|
||||
const match = findExcelForProduct(params);
|
||||
if (!match || match.rows.length === 0) return null;
|
||||
|
||||
const rows = match.rows;
|
||||
const sample = rows[0];
|
||||
|
||||
// Find cross-section column
|
||||
const csKey = guessColumnKey(sample, [
|
||||
/number of cores and cross-section/i,
|
||||
/cross.?section/i,
|
||||
/ross section conductor/i,
|
||||
]);
|
||||
|
||||
if (!csKey) return null;
|
||||
|
||||
// Extract configurations
|
||||
const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]);
|
||||
|
||||
const configurations = rows
|
||||
.map(r => {
|
||||
const cs = normalizeValue(String(r?.[csKey] ?? ''));
|
||||
const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : '';
|
||||
if (!cs) return '';
|
||||
if (!v) return cs;
|
||||
const vHasUnit = /\bkv\b/i.test(v);
|
||||
const vText = vHasUnit ? v : `${v} kV`;
|
||||
return `${cs} - ${vText}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (configurations.length === 0) return null;
|
||||
|
||||
// Extract technical attributes
|
||||
const attributes: Array<{ name: string; options: string[] }> = [];
|
||||
|
||||
// Key technical columns
|
||||
const outerKey = guessColumnKey(sample, [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]);
|
||||
const weightKey = guessColumnKey(sample, [/weight\b/i, /gewicht/i, /cable weight/i]);
|
||||
const dcResKey = guessColumnKey(sample, [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]);
|
||||
const ratedVoltKey = voltageKey;
|
||||
const testVoltKey = guessColumnKey(sample, [/test voltage/i, /prüfspannung/i]);
|
||||
const tempRangeKey = guessColumnKey(sample, [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]);
|
||||
const conductorKey = guessColumnKey(sample, [/^conductor$/i]);
|
||||
const insulationKey = guessColumnKey(sample, [/^insulation$/i]);
|
||||
const sheathKey = guessColumnKey(sample, [/^sheath$/i]);
|
||||
const normKey = guessColumnKey(sample, [/^norm$/i, /^standard$/i]);
|
||||
const cprKey = guessColumnKey(sample, [/cpr class/i]);
|
||||
const packagingKey = guessColumnKey(sample, [/^packaging$/i]);
|
||||
const shapeKey = guessColumnKey(sample, [/shape of conductor/i]);
|
||||
const flameKey = guessColumnKey(sample, [/flame retardant/i]);
|
||||
const diamCondKey = guessColumnKey(sample, [/diameter conductor/i]);
|
||||
const diamInsKey = guessColumnKey(sample, [/diameter over insulation/i]);
|
||||
const diamScreenKey = guessColumnKey(sample, [/diameter over screen/i]);
|
||||
const metalScreenKey = guessColumnKey(sample, [/metallic screen/i]);
|
||||
const capacitanceKey = guessColumnKey(sample, [/capacitance/i]);
|
||||
const reactanceKey = guessColumnKey(sample, [/reactance/i]);
|
||||
const electricalStressKey = guessColumnKey(sample, [/electrical stress/i]);
|
||||
const pullingForceKey = guessColumnKey(sample, [/max\. pulling force/i, /pulling force/i]);
|
||||
const heatingTrefoilKey = guessColumnKey(sample, [/heating time constant.*trefoil/i]);
|
||||
const heatingFlatKey = guessColumnKey(sample, [/heating time constant.*flat/i]);
|
||||
const currentAirTrefoilKey = guessColumnKey(sample, [/current ratings in air.*trefoil/i]);
|
||||
const currentAirFlatKey = guessColumnKey(sample, [/current ratings in air.*flat/i]);
|
||||
const currentGroundTrefoilKey = guessColumnKey(sample, [/current ratings in ground.*trefoil/i]);
|
||||
const currentGroundFlatKey = guessColumnKey(sample, [/current ratings in ground.*flat/i]);
|
||||
const scCurrentCondKey = guessColumnKey(sample, [/conductor shortcircuit current/i]);
|
||||
const scCurrentScreenKey = guessColumnKey(sample, [/screen shortcircuit current/i]);
|
||||
const minLayKey = guessColumnKey(sample, [/minimal temperature for laying/i]);
|
||||
const minStoreKey = guessColumnKey(sample, [/minimal storage temperature/i]);
|
||||
const maxOpKey = guessColumnKey(sample, [/maximal operating conductor temperature/i, /max\. operating/i]);
|
||||
const maxScKey = guessColumnKey(sample, [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]);
|
||||
const insThkKey = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]);
|
||||
const sheathThkKey = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i]);
|
||||
const maxResKey = guessColumnKey(sample, [/maximum resistance of conductor/i]);
|
||||
const bendKey = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]);
|
||||
|
||||
// Helper to add attribute
|
||||
const addAttr = (name: string, key: string | null, unit?: string) => {
|
||||
if (!key) return;
|
||||
const options = rows
|
||||
.map(r => normalizeValue(String(r?.[key] ?? '')))
|
||||
.map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v))
|
||||
.filter(Boolean);
|
||||
|
||||
if (options.length === 0) return;
|
||||
|
||||
const uniqueOptions = getUniqueNonEmpty(options);
|
||||
attributes.push({ name, options: uniqueOptions });
|
||||
};
|
||||
|
||||
// Add attributes
|
||||
addAttr('Outer diameter', outerKey, 'mm');
|
||||
addAttr('Weight', weightKey, 'kg/km');
|
||||
addAttr('DC resistance at 20 °C', dcResKey, 'Ω/km');
|
||||
addAttr('Rated voltage', ratedVoltKey, '');
|
||||
addAttr('Test voltage', testVoltKey, '');
|
||||
addAttr('Operating temperature range', tempRangeKey, '');
|
||||
addAttr('Minimal temperature for laying', minLayKey, '');
|
||||
addAttr('Minimal storage temperature', minStoreKey, '');
|
||||
addAttr('Maximal operating conductor temperature', maxOpKey, '');
|
||||
addAttr('Maximal short-circuit temperature', maxScKey, '');
|
||||
addAttr('Nominal insulation thickness', insThkKey, 'mm');
|
||||
addAttr('Nominal sheath thickness', sheathThkKey, 'mm');
|
||||
addAttr('Maximum resistance of conductor', maxResKey, 'Ω/km');
|
||||
addAttr('Conductor', conductorKey, '');
|
||||
addAttr('Insulation', insulationKey, '');
|
||||
addAttr('Sheath', sheathKey, '');
|
||||
addAttr('Standard', normKey, '');
|
||||
addAttr('Conductor diameter', diamCondKey, 'mm');
|
||||
addAttr('Insulation diameter', diamInsKey, 'mm');
|
||||
addAttr('Screen diameter', diamScreenKey, 'mm');
|
||||
addAttr('Metallic screen', metalScreenKey, '');
|
||||
addAttr('Max. pulling force', pullingForceKey, '');
|
||||
addAttr('Electrical stress conductor', electricalStressKey, '');
|
||||
addAttr('Electrical stress insulation', electricalStressKey, '');
|
||||
addAttr('Reactance', reactanceKey, '');
|
||||
addAttr('Heating time constant trefoil', heatingTrefoilKey, 's');
|
||||
addAttr('Heating time constant flat', heatingFlatKey, 's');
|
||||
addAttr('Flame retardant', flameKey, '');
|
||||
addAttr('CPR class', cprKey, '');
|
||||
addAttr('Packaging', packagingKey, '');
|
||||
addAttr('Bending radius', bendKey, 'mm');
|
||||
addAttr('Shape of conductor', shapeKey, '');
|
||||
addAttr('Capacitance', capacitanceKey, 'μF/km');
|
||||
addAttr('Current ratings in air, trefoil', currentAirTrefoilKey, 'A');
|
||||
addAttr('Current ratings in air, flat', currentAirFlatKey, 'A');
|
||||
addAttr('Current ratings in ground, trefoil', currentGroundTrefoilKey, 'A');
|
||||
addAttr('Current ratings in ground, flat', currentGroundFlatKey, 'A');
|
||||
addAttr('Conductor shortcircuit current', scCurrentCondKey, 'kA');
|
||||
addAttr('Screen shortcircuit current', scCurrentScreenKey, 'kA');
|
||||
|
||||
return {
|
||||
configurations,
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw Excel rows for a product (for detailed inspection)
|
||||
*/
|
||||
export function getExcelRowsForProduct(params: ProductLookupParams): ExcelRow[] {
|
||||
const match = findExcelForProduct(params);
|
||||
return match?.rows || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the Excel index cache (useful for development)
|
||||
*/
|
||||
export function clearExcelCache(): void {
|
||||
EXCEL_INDEX = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload Excel data on module initialization
|
||||
* This ensures the cache is built during build time
|
||||
*/
|
||||
export function preloadExcelData(): void {
|
||||
getExcelIndex();
|
||||
}
|
||||
|
||||
// Preload when imported
|
||||
if (require.main === module) {
|
||||
preloadExcelData();
|
||||
}
|
||||
@@ -189,62 +189,6 @@ export const minorLocations: Location[] = [
|
||||
y: 83.33,
|
||||
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',
|
||||
name: 'PV-Anlage, 01909 Großhartau',
|
||||
|
||||
@@ -11,10 +11,21 @@ export async function getOgFonts() {
|
||||
|
||||
try {
|
||||
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
const boldFontBuffer = readFileSync(boldFontPath);
|
||||
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(
|
||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
||||
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
|
||||
);
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
1436
lib/pdf-brochure.tsx
1436
lib/pdf-brochure.tsx
File diff suppressed because it is too large
Load Diff
329
lib/pdf-page.tsx
329
lib/pdf-page.tsx
@@ -1,329 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
|
||||
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
fonts: [
|
||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
green: '#4da612',
|
||||
greenLight: '#e8f5d8',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const MARGIN = 56;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
color: C.gray900,
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 80,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 0,
|
||||
paddingHorizontal: MARGIN,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
logoText: {
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
docTitle: {
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: C.green,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: MARGIN,
|
||||
},
|
||||
|
||||
pageTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginBottom: 8,
|
||||
marginTop: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
|
||||
accentBar: {
|
||||
width: 30,
|
||||
height: 2,
|
||||
backgroundColor: C.green,
|
||||
marginBottom: 20,
|
||||
borderRadius: 1,
|
||||
},
|
||||
|
||||
// Lexical Elements
|
||||
paragraph: {
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 12,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 20,
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
list: {
|
||||
marginBottom: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 4,
|
||||
},
|
||||
listItemBullet: {
|
||||
width: 12,
|
||||
fontSize: 10,
|
||||
color: C.green,
|
||||
fontWeight: 700,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.7,
|
||||
},
|
||||
link: {
|
||||
color: C.green,
|
||||
textDecoration: 'none',
|
||||
},
|
||||
textBold: {
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: C.navyDeep,
|
||||
},
|
||||
textItalic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Footer — matches brochure style
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 28,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: C.green,
|
||||
},
|
||||
|
||||
footerText: {
|
||||
fontSize: 7,
|
||||
color: C.gray400,
|
||||
fontWeight: 400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Lexical to React-PDF Renderer ────────────────────────────────
|
||||
|
||||
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case 'text': {
|
||||
const format = node.format || 0;
|
||||
const isBold = (format & 1) !== 0;
|
||||
const isItalic = (format & 2) !== 0;
|
||||
|
||||
let elementStyle: any = {};
|
||||
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
|
||||
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
|
||||
|
||||
return (
|
||||
<Text key={idx} style={elementStyle}>
|
||||
{node.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
return (
|
||||
<Text key={idx} style={styles.paragraph}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'heading': {
|
||||
let hStyle = styles.heading3;
|
||||
if (node.tag === 'h1') hStyle = styles.heading1;
|
||||
if (node.tag === 'h2') hStyle = styles.heading2;
|
||||
|
||||
return (
|
||||
<Text key={idx} style={hStyle}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
return (
|
||||
<View key={idx} style={styles.list}>
|
||||
{node.children?.map((child: any, i: number) => {
|
||||
if (child.type === 'listitem') {
|
||||
return (
|
||||
<View key={i} style={styles.listItem}>
|
||||
<Text style={styles.listItemBullet}>
|
||||
{node.listType === 'number' ? `${i + 1}.` : '•'}
|
||||
</Text>
|
||||
<Text style={styles.listItemContent}>
|
||||
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return renderLexicalNode(child, i);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
const href = node.fields?.url || node.url || '#';
|
||||
return (
|
||||
<Link key={idx} src={href} style={styles.link}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
case 'linebreak': {
|
||||
return <Text key={idx}>{'\n'}</Text>;
|
||||
}
|
||||
|
||||
// Ignore payload blocks recursively to avoid crashing
|
||||
case 'block':
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (node.children) {
|
||||
return (
|
||||
<Text key={idx}>
|
||||
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface PDFPageProps {
|
||||
page: any;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Hero Header */}
|
||||
<View style={styles.hero} fixed>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.pageTitle}>{page.title}</Text>
|
||||
<View style={styles.accentBar} />
|
||||
|
||||
<View>
|
||||
{page.content?.root?.children?.map((node: any, i: number) =>
|
||||
renderLexicalNode(node, i),
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Minimal footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>{dateStr}</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export const getLocalBusinessSchema = () => ({
|
||||
url: SITE_URL,
|
||||
logo: LOGO_URL,
|
||||
image: LOGO_URL,
|
||||
description: 'Ihr Partner für Kabelleitungstiefbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||
description: 'Ihr Partner für Kabelleitungsnetzbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||
telephone: '+4935616857733',
|
||||
email: 'info@e-tib.com',
|
||||
address: {
|
||||
|
||||
@@ -35,10 +35,10 @@ export const standorteData: StandortData[] = [
|
||||
{
|
||||
id: 'guben',
|
||||
name: 'E-TIB GmbH',
|
||||
shortName: 'Guben (Hauptsitz)',
|
||||
shortName: 'Standort Brandenburg (Hauptsitz)',
|
||||
type: 'hq',
|
||||
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 Kabelleitungstiefbau 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.',
|
||||
en: 'Our headquarters in Guben forms the strategic and operational center of the E-TIB Group. From here, we manage large-scale civil engineering projects nationwide and coordinate our state-of-the-art machinery fleet.'
|
||||
},
|
||||
address: {
|
||||
@@ -56,18 +56,18 @@ export const standorteData: StandortData[] = [
|
||||
keyFeatures: {
|
||||
de: [
|
||||
'Zentrale strategische Steuerung',
|
||||
'Kabelleitungstiefbau & Infrastruktur',
|
||||
'Kabelleitungsnetzbau & Infrastruktur',
|
||||
'Koordination Großmaschinenpark',
|
||||
'Zentrales Projektmanagement'
|
||||
],
|
||||
en: [
|
||||
'Central strategic management',
|
||||
'Underground cable engineering & infrastructure',
|
||||
'Cable network engineering & infrastructure',
|
||||
'Coordination of heavy machinery',
|
||||
'Central project management'
|
||||
]
|
||||
},
|
||||
image: '/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg',
|
||||
image: '/assets/photos/DSC02114.jpg',
|
||||
gallery: [
|
||||
'/assets/photos/DSC02114.jpg',
|
||||
'/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-45.jpg'
|
||||
@@ -117,7 +117,7 @@ export const standorteData: StandortData[] = [
|
||||
{
|
||||
id: 'buelstedt',
|
||||
name: 'E-TIB Ingenieurgesellschaft mbH',
|
||||
shortName: 'Bülstedt (Ingenieurgesellschaft)',
|
||||
shortName: 'Standort Niedersachsen',
|
||||
type: 'branch',
|
||||
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.',
|
||||
|
||||
31
make_contact_sheet.py
Normal file
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")
|
||||
@@ -20,11 +20,11 @@
|
||||
"categories": {}
|
||||
},
|
||||
"Index": {
|
||||
"title": "E-TIB GmbH – Experts in Underground Cable Engineering & Infrastructure",
|
||||
"description": "Your partner for underground cable engineering, horizontal drilling, and fiber optic installation in Guben and nationwide.",
|
||||
"title": "E-TIB GmbH – Experts in Cable network engineering & Infrastructure",
|
||||
"description": "Your partner for Cable network engineering, horizontal drilling, and fiber optic installation in Guben and nationwide.",
|
||||
"meta": {
|
||||
"title": "E-TIB GmbH | Specialist for Underground Cable Engineering & Grid Expansion",
|
||||
"description": "E-TIB GmbH from Guben is your expert for underground cable engineering, directional drilling, plowing works, and electrical installation up to 110 kV."
|
||||
"title": "E-TIB GmbH | Specialist for Cable network engineering & Grid Expansion",
|
||||
"description": "E-TIB GmbH from Guben is your expert for Cable network engineering, directional drilling, plowing works, and electrical installation up to 110 kV."
|
||||
}
|
||||
},
|
||||
"Navigation": {
|
||||
@@ -49,12 +49,12 @@
|
||||
"career": "Career",
|
||||
"contact": "Contact",
|
||||
"copyright": "Copyright © {year} E-TIB GmbH. All rights reserved.",
|
||||
"tagline": "Experts for underground cable engineering and the execution of electrical infrastructure projects. Quality and reliability from Guben."
|
||||
"tagline": "Experts for Cable network engineering and the execution of electrical infrastructure projects. Quality and reliability from Guben."
|
||||
},
|
||||
"Team": {
|
||||
"meta": {
|
||||
"title": "About Us | The E-TIB Group",
|
||||
"description": "Get to know the E-TIB Group: Specialists for underground cable engineering, drilling technology, and engineering services."
|
||||
"description": "Get to know the E-TIB Group: Specialists for Cable network engineering, drilling technology, and engineering services."
|
||||
},
|
||||
"hero": {
|
||||
"title": "Infrastructure for the Future",
|
||||
@@ -71,7 +71,7 @@
|
||||
"subtitle": "Specialized units for complex projects",
|
||||
"etib": {
|
||||
"title": "E‑TIB GmbH",
|
||||
"desc": "Execution of electrical infrastructure projects and underground cable engineering."
|
||||
"desc": "Execution of electrical infrastructure projects and Cable network engineering."
|
||||
},
|
||||
"bohrtechnik": {
|
||||
"title": "E‑TIB Bohrtechnik GmbH",
|
||||
@@ -90,7 +90,7 @@
|
||||
"Contact": {
|
||||
"meta": {
|
||||
"title": "Contact | E-TIB GmbH Guben",
|
||||
"description": "Get in touch with E-TIB GmbH in Guben. Your partner for underground cable engineering and grid infrastructure."
|
||||
"description": "Get in touch with E-TIB GmbH in Guben. Your partner for Cable network engineering and grid infrastructure."
|
||||
},
|
||||
"title": "Contact Us",
|
||||
"subtitle": "Do you have questions about our services or a specific project? We are here for you.",
|
||||
@@ -135,7 +135,7 @@
|
||||
"subtitle": "Comprehensive solutions for the expansion of modern energy and data networks.",
|
||||
"badge": "Service Spectrum",
|
||||
"items": {
|
||||
"kabelbau": "Underground Cable Engineering",
|
||||
"kabelbau": "Cable network engineering",
|
||||
"pflugarbeiten": "Cable Plowing",
|
||||
"bohrungen": "Horizontal Directional Drilling",
|
||||
"elektromontage": "Electrical Installations up to 110 kV",
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"Home": {
|
||||
"hero": {
|
||||
"title": "THE EXPERTS FOR <green>UNDERGROUND CABLE ENGINEERING</green>",
|
||||
"title": "THE EXPERTS FOR <green>Cable network engineering</green>",
|
||||
"subtitle": "We help expanding the energy cable networks for a green future.",
|
||||
"cta": "Request now",
|
||||
"searchPlaceholder": "Describe project or search for cables...",
|
||||
@@ -207,7 +207,7 @@
|
||||
"Blog": {
|
||||
"meta": {
|
||||
"title": "Blog & News",
|
||||
"description": "Latest news and insights from the world of underground cable engineering and infrastructure."
|
||||
"description": "Latest news and insights from the world of Cable network engineering and infrastructure."
|
||||
},
|
||||
"featuredPost": "Featured Post",
|
||||
"readFullArticle": "Read Full Article",
|
||||
@@ -226,7 +226,7 @@
|
||||
"ReferencesSlider": {
|
||||
"badge": "Selected Projects",
|
||||
"title": "References & Successes",
|
||||
"description": "A selection of our successfully completed projects in underground cable engineering, drilling technology, and grid infrastructure.",
|
||||
"description": "A selection of our successfully completed projects in Cable network engineering, drilling technology, and grid infrastructure.",
|
||||
"ctaLabel": "View All References"
|
||||
},
|
||||
"CompanyTimeline": {
|
||||
|
||||
@@ -15,6 +15,10 @@ const nextConfig = {
|
||||
pagesBufferLength: 2,
|
||||
},
|
||||
experimental: {
|
||||
staleTimes: {
|
||||
dynamic: 0,
|
||||
static: 30,
|
||||
},
|
||||
optimizePackageImports: [
|
||||
'lucide-react',
|
||||
'framer-motion',
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"@mintel/next-utils": "1.9.5",
|
||||
"@next/mdx": "^16.1.6",
|
||||
"@react-email/components": "1.0.8",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@sentry/nextjs": "^10.39.0",
|
||||
@@ -31,7 +30,6 @@
|
||||
"next-intl": "^4.8.2",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"nodemailer": "^8.0.7",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"react": "^19.2.4",
|
||||
@@ -124,10 +122,6 @@
|
||||
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
||||
"check:forms": "tsx ./scripts/check-forms.ts",
|
||||
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.tsx",
|
||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
|
||||
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
|
||||
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging",
|
||||
"assets:push:prod": "bash ./scripts/assets-sync.sh local prod",
|
||||
"assets:pull:testing": "bash ./scripts/assets-sync.sh testing local",
|
||||
@@ -144,7 +138,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.2.13-rc.46",
|
||||
"version": "2.2.34-rc.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
306
pnpm-lock.yaml
generated
306
pnpm-lock.yaml
generated
@@ -33,9 +33,6 @@ importers:
|
||||
'@react-email/components':
|
||||
specifier: 1.0.8
|
||||
version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@react-pdf/renderer':
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2(react@19.2.4)
|
||||
'@react-three/drei':
|
||||
specifier: ^10.7.7
|
||||
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)
|
||||
@@ -93,9 +90,6 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^8.0.7
|
||||
version: 8.0.7
|
||||
pdf-lib:
|
||||
specifier: ^1.17.1
|
||||
version: 1.17.1
|
||||
pino:
|
||||
specifier: ^10.3.0
|
||||
version: 10.3.1
|
||||
@@ -1897,12 +1891,6 @@ packages:
|
||||
'@paulirish/trace_engine@0.0.53':
|
||||
resolution: {integrity: sha512-PUl/vlfo08Oj804VI5nDPeSk9vyslnBlVzDDwFt8SUVxY8+KdGMkra/vrXjEEHe8gb7+RqVTfOIlGw0nyrEelA==}
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
|
||||
|
||||
'@pdf-lib/upng@1.0.1':
|
||||
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
@@ -2221,49 +2209,6 @@ packages:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
|
||||
'@react-pdf/fns@3.1.2':
|
||||
resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==}
|
||||
|
||||
'@react-pdf/font@4.0.4':
|
||||
resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==}
|
||||
|
||||
'@react-pdf/image@3.0.4':
|
||||
resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==}
|
||||
|
||||
'@react-pdf/layout@4.4.2':
|
||||
resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==}
|
||||
|
||||
'@react-pdf/pdfkit@4.1.0':
|
||||
resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==}
|
||||
|
||||
'@react-pdf/png-js@3.0.0':
|
||||
resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==}
|
||||
|
||||
'@react-pdf/primitives@4.1.1':
|
||||
resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==}
|
||||
|
||||
'@react-pdf/reconciler@2.0.0':
|
||||
resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-pdf/render@4.3.2':
|
||||
resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==}
|
||||
|
||||
'@react-pdf/renderer@4.3.2':
|
||||
resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-pdf/stylesheet@6.1.2':
|
||||
resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==}
|
||||
|
||||
'@react-pdf/textkit@6.1.0':
|
||||
resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==}
|
||||
|
||||
'@react-pdf/types@2.9.2':
|
||||
resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==}
|
||||
|
||||
'@react-three/drei@10.7.7':
|
||||
resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==}
|
||||
peerDependencies:
|
||||
@@ -3438,9 +3383,6 @@ packages:
|
||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
abs-svg-path@0.1.1:
|
||||
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||
|
||||
accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3776,9 +3718,6 @@ packages:
|
||||
brotli@1.3.3:
|
||||
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
|
||||
|
||||
browserify-zlib@0.2.0:
|
||||
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
|
||||
|
||||
browserslist@4.28.2:
|
||||
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
@@ -3973,9 +3912,6 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
color-string@1.9.1:
|
||||
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
@@ -4112,9 +4048,6 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
|
||||
crypto-random-string@2.0.0:
|
||||
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4430,9 +4363,6 @@ packages:
|
||||
electron-to-chromium@1.5.330:
|
||||
resolution: {integrity: sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==}
|
||||
|
||||
emoji-regex-xs@1.0.0:
|
||||
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
@@ -5170,12 +5100,6 @@ packages:
|
||||
resolution: {integrity: sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
hsl-to-hex@1.0.0:
|
||||
resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
|
||||
|
||||
hsl-to-rgb-for-reals@1.1.1:
|
||||
resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
@@ -5257,9 +5181,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
hyphen@1.14.1:
|
||||
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
|
||||
|
||||
i18next-fs-backend@2.6.3:
|
||||
resolution: {integrity: sha512-/5aW996nbolGh/qVU9urjAJ2QiXpiyGuhG8x/ZvvZQ3bMlrK+ouUdD6whS4e+sU9CCdUIzl7x/heEjsBh/3xtw==}
|
||||
|
||||
@@ -5393,9 +5314,6 @@ packages:
|
||||
is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
|
||||
is-arrayish@0.3.4:
|
||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||
|
||||
is-async-function@2.1.1:
|
||||
resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5554,9 +5472,6 @@ packages:
|
||||
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
is-weakmap@2.0.2:
|
||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5598,9 +5513,6 @@ packages:
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
jay-peg@1.1.1:
|
||||
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
|
||||
|
||||
jest-worker@27.5.1:
|
||||
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
@@ -6034,9 +5946,6 @@ packages:
|
||||
mdn-data@2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
media-engine@1.0.3:
|
||||
resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -6383,9 +6292,6 @@ packages:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
normalize-svg-path@1.1.0:
|
||||
resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6532,9 +6438,6 @@ packages:
|
||||
pako@0.2.9:
|
||||
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6553,9 +6456,6 @@ packages:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
parse-svg-path@0.1.2:
|
||||
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
|
||||
|
||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
|
||||
|
||||
@@ -6607,9 +6507,6 @@ packages:
|
||||
pause-stream@0.0.11:
|
||||
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==}
|
||||
engines: {node: '>=20.16.0 <21 || >=22.3.0'}
|
||||
@@ -6826,9 +6723,6 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
queue@6.0.2:
|
||||
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
@@ -7122,9 +7016,6 @@ packages:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.25.0-rc-603e6108-20241029:
|
||||
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
@@ -7222,9 +7113,6 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
sirv@2.0.4:
|
||||
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -7401,9 +7289,6 @@ packages:
|
||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
@@ -7489,9 +7374,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=17.0'
|
||||
|
||||
svg-arc-to-cubic-bezier@3.2.0:
|
||||
resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
|
||||
|
||||
svg-to-pdfkit@0.1.8:
|
||||
resolution: {integrity: sha512-QItiGZBy5TstGy+q8mjQTMGRlDDOARXLxH+sgVm1n/LYeo0zFcQlcCh8m4zi8QxctrxB9Kue/lStc/RD5iLadQ==}
|
||||
|
||||
@@ -7875,10 +7757,6 @@ packages:
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite-compatible-readable-stream@3.6.1:
|
||||
resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
vite@8.0.3:
|
||||
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -8217,9 +8095,6 @@ packages:
|
||||
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
yoga-layout@3.2.1:
|
||||
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
|
||||
|
||||
zod-validation-error@4.0.2:
|
||||
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -9863,14 +9738,6 @@ snapshots:
|
||||
legacy-javascript: 0.0.1
|
||||
third-party-web: 0.29.2
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
'@pdf-lib/upng@1.0.1':
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
@@ -10150,107 +10017,6 @@ snapshots:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@react-pdf/fns@3.1.2': {}
|
||||
|
||||
'@react-pdf/font@4.0.4':
|
||||
dependencies:
|
||||
'@react-pdf/pdfkit': 4.1.0
|
||||
'@react-pdf/types': 2.9.2
|
||||
fontkit: 2.0.4
|
||||
is-url: 1.2.4
|
||||
|
||||
'@react-pdf/image@3.0.4':
|
||||
dependencies:
|
||||
'@react-pdf/png-js': 3.0.0
|
||||
jay-peg: 1.1.1
|
||||
|
||||
'@react-pdf/layout@4.4.2':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/image': 3.0.4
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/stylesheet': 6.1.2
|
||||
'@react-pdf/textkit': 6.1.0
|
||||
'@react-pdf/types': 2.9.2
|
||||
emoji-regex-xs: 1.0.0
|
||||
queue: 6.0.2
|
||||
yoga-layout: 3.2.1
|
||||
|
||||
'@react-pdf/pdfkit@4.1.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@react-pdf/png-js': 3.0.0
|
||||
browserify-zlib: 0.2.0
|
||||
crypto-js: 4.2.0
|
||||
fontkit: 2.0.4
|
||||
jay-peg: 1.1.1
|
||||
linebreak: 1.1.0
|
||||
vite-compatible-readable-stream: 3.6.1
|
||||
|
||||
'@react-pdf/png-js@3.0.0':
|
||||
dependencies:
|
||||
browserify-zlib: 0.2.0
|
||||
|
||||
'@react-pdf/primitives@4.1.1': {}
|
||||
|
||||
'@react-pdf/reconciler@2.0.0(react@19.2.4)':
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
react: 19.2.4
|
||||
scheduler: 0.25.0-rc-603e6108-20241029
|
||||
|
||||
'@react-pdf/render@4.3.2':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/textkit': 6.1.0
|
||||
'@react-pdf/types': 2.9.2
|
||||
abs-svg-path: 0.1.1
|
||||
color-string: 1.9.1
|
||||
normalize-svg-path: 1.1.0
|
||||
parse-svg-path: 0.1.2
|
||||
svg-arc-to-cubic-bezier: 3.2.0
|
||||
|
||||
'@react-pdf/renderer@4.3.2(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/font': 4.0.4
|
||||
'@react-pdf/layout': 4.4.2
|
||||
'@react-pdf/pdfkit': 4.1.0
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/reconciler': 2.0.0(react@19.2.4)
|
||||
'@react-pdf/render': 4.3.2
|
||||
'@react-pdf/types': 2.9.2
|
||||
events: 3.3.0
|
||||
object-assign: 4.1.1
|
||||
prop-types: 15.8.1
|
||||
queue: 6.0.2
|
||||
react: 19.2.4
|
||||
|
||||
'@react-pdf/stylesheet@6.1.2':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/types': 2.9.2
|
||||
color-string: 1.9.1
|
||||
hsl-to-hex: 1.0.0
|
||||
media-engine: 1.0.3
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
'@react-pdf/textkit@6.1.0':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
bidi-js: 1.0.3
|
||||
hyphen: 1.14.1
|
||||
unicode-properties: 1.4.1
|
||||
|
||||
'@react-pdf/types@2.9.2':
|
||||
dependencies:
|
||||
'@react-pdf/font': 4.0.4
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/stylesheet': 6.1.2
|
||||
|
||||
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -11429,8 +11195,6 @@ snapshots:
|
||||
|
||||
abbrev@2.0.0: {}
|
||||
|
||||
abs-svg-path@0.1.1: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
@@ -11751,10 +11515,6 @@ snapshots:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
browserify-zlib@0.2.0:
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
browserslist@4.28.2:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.13
|
||||
@@ -11969,11 +11729,6 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
color-string@1.9.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
simple-swizzle: 0.2.4
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
@@ -12120,8 +11875,6 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crypto-js@4.2.0: {}
|
||||
|
||||
crypto-random-string@2.0.0: {}
|
||||
|
||||
csp_evaluator@1.1.5: {}
|
||||
@@ -12453,8 +12206,6 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.330: {}
|
||||
|
||||
emoji-regex-xs@1.0.0: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
@@ -13516,12 +13267,6 @@ snapshots:
|
||||
|
||||
hoopy@0.1.4: {}
|
||||
|
||||
hsl-to-hex@1.0.0:
|
||||
dependencies:
|
||||
hsl-to-rgb-for-reals: 1.1.1
|
||||
|
||||
hsl-to-rgb-for-reals@1.1.1: {}
|
||||
|
||||
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
|
||||
@@ -13620,8 +13365,6 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
hyphen@1.14.1: {}
|
||||
|
||||
i18next-fs-backend@2.6.3: {}
|
||||
|
||||
i18next@25.10.10(typescript@5.9.3):
|
||||
@@ -13773,8 +13516,6 @@ snapshots:
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
is-arrayish@0.3.4: {}
|
||||
|
||||
is-async-function@2.1.1:
|
||||
dependencies:
|
||||
async-function: 1.0.0
|
||||
@@ -13912,8 +13653,6 @@ snapshots:
|
||||
|
||||
is-unicode-supported@2.1.0: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
|
||||
is-weakref@1.1.1:
|
||||
@@ -13964,10 +13703,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jay-peg@1.1.1:
|
||||
dependencies:
|
||||
restructure: 3.0.2
|
||||
|
||||
jest-worker@27.5.1:
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
@@ -14516,8 +14251,6 @@ snapshots:
|
||||
|
||||
mdn-data@2.27.1: {}
|
||||
|
||||
media-engine@1.0.3: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
meow@13.2.0: {}
|
||||
@@ -14984,10 +14717,6 @@ snapshots:
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
normalize-svg-path@1.1.0:
|
||||
dependencies:
|
||||
svg-arc-to-cubic-bezier: 3.2.0
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -15198,8 +14927,6 @@ snapshots:
|
||||
|
||||
pako@0.2.9: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -15227,8 +14954,6 @@ snapshots:
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
parse-svg-path@0.1.2: {}
|
||||
|
||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||
dependencies:
|
||||
domhandler: 5.0.3
|
||||
@@ -15279,13 +15004,6 @@ snapshots:
|
||||
dependencies:
|
||||
through: 2.3.8
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
dependencies:
|
||||
'@pdf-lib/standard-fonts': 1.0.0
|
||||
'@pdf-lib/upng': 1.0.1
|
||||
pako: 1.0.11
|
||||
tslib: 1.14.1
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
dependencies:
|
||||
'@napi-rs/canvas': 0.1.80
|
||||
@@ -15535,10 +15253,6 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
queue@6.0.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
@@ -15957,8 +15671,6 @@ snapshots:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.25.0-rc-603e6108-20241029: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
schema-dts@1.1.5: {}
|
||||
@@ -16111,10 +15823,6 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.3.4
|
||||
|
||||
sirv@2.0.4:
|
||||
dependencies:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
@@ -16351,10 +16059,6 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
@@ -16425,8 +16129,6 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
svg-arc-to-cubic-bezier@3.2.0: {}
|
||||
|
||||
svg-to-pdfkit@0.1.8:
|
||||
dependencies:
|
||||
pdfkit: 0.18.0
|
||||
@@ -16895,12 +16597,6 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-compatible-readable-stream@3.6.1:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
@@ -17232,8 +16928,6 @@ snapshots:
|
||||
|
||||
yoctocolors@2.1.2: {}
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zod-validation-error@4.0.2(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
3
proxy.ts
3
proxy.ts
@@ -9,8 +9,9 @@ export default createMiddleware({
|
||||
export const config = {
|
||||
// Match all pathnames except for
|
||||
// - /api (API routes)
|
||||
// - /stats (Analytics proxy)
|
||||
// - /_next (Next.js internals)
|
||||
// - /_static (inside /public)
|
||||
// - 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/fonts/Inter-Bold.woff
Normal file
BIN
public/fonts/Inter-Bold.woff
Normal file
Binary file not shown.
BIN
public/fonts/Inter-Regular.woff
Normal file
BIN
public/fonts/Inter-Regular.woff
Normal file
Binary file not shown.
@@ -9,8 +9,8 @@ echo "🚀 Starting High-Fidelity Local Audit..."
|
||||
|
||||
# 1. Environment and Infrastructure
|
||||
export DOCKER_HOST="unix:///Users/marcmintel/.docker/run/docker.sock"
|
||||
export IMGPROXY_URL="http://klz-imgproxy:8080"
|
||||
export NEXT_URL="http://klz.localhost"
|
||||
export IMGPROXY_URL="http://etib-imgproxy:8080"
|
||||
export NEXT_URL="http://etib.localhost"
|
||||
export NEXT_PUBLIC_CI=true
|
||||
export CI=true
|
||||
|
||||
@@ -20,14 +20,14 @@ docker volume create klz_db_data 2>/dev/null || true
|
||||
# 2. Start infra services (DB, CMS, Gatekeeper)
|
||||
echo "📦 Starting infrastructure services..."
|
||||
# Using --remove-orphans to ensure a clean state
|
||||
docker-compose up -d --remove-orphans klz-db klz-gatekeeper
|
||||
docker compose up -d --remove-orphans etib-db etib-gatekeeper
|
||||
|
||||
# 3. Build and Start klz-app in Production Mode
|
||||
echo "🏗️ Building and starting klz-app (Production)..."
|
||||
# 3. Build and Start etib-app in Production Mode
|
||||
echo "🏗️ Building and starting etib-app (Production)..."
|
||||
# We bypass the dev override by explicitly using the base compose file
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_URL \
|
||||
NEXT_PUBLIC_CI=true \
|
||||
docker-compose -f docker-compose.yml up -d --build klz-app
|
||||
docker compose -f docker-compose.yml up -d --build etib-app
|
||||
|
||||
# 4. Wait for application to be ready
|
||||
echo "⏳ Waiting for application to be healthy..."
|
||||
@@ -54,4 +54,4 @@ echo "♿ Executing WCAG Audit..."
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=10 pnpm run check:wcag "$NEXT_URL"
|
||||
|
||||
echo "✨ Audit completed! Summary above."
|
||||
echo "💡 You can stop the production app with: docker-compose stop klz-app"
|
||||
echo "💡 You can stop the production app with: docker-compose stop etib-app"
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Master Brochure Generator
|
||||
*
|
||||
* Generates a multi-page PDF brochure containing all products.
|
||||
* Combines Payload CMS marketing text with Excel technical data.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as React from 'react';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { getAllProducts } from '../lib/products';
|
||||
import { getExcelTechnicalDataForProduct, preloadExcelData } from '../lib/excel-products';
|
||||
import { PDFBrochure } from '../lib/pdf-brochure';
|
||||
import { ProductData as PDFProductData } from '../lib/pdf-datasheet';
|
||||
|
||||
const CONFIG = {
|
||||
outputDir: path.join(process.cwd(), 'public/downloads'),
|
||||
locales: ['en', 'de'] as const,
|
||||
} as const;
|
||||
|
||||
async function ensureOutputDir() {
|
||||
if (!fs.existsSync(CONFIG.outputDir)) {
|
||||
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBrochure(locale: 'en' | 'de') {
|
||||
console.log(`\nGenerating brochure for locale: ${locale.toUpperCase()}...`);
|
||||
|
||||
// 1. Load data
|
||||
const products = await getAllProducts(locale);
|
||||
console.log(`- Loaded ${products.length} products from Payload`);
|
||||
|
||||
// 2. Map and Enrich
|
||||
const pdfProducts: PDFProductData[] = products.map((p, index) => {
|
||||
const excelData = getExcelTechnicalDataForProduct({
|
||||
slug: p.slug,
|
||||
sku: p.frontmatter.sku,
|
||||
name: p.frontmatter.title,
|
||||
});
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
name: p.frontmatter.title,
|
||||
sku: p.frontmatter.sku,
|
||||
shortDescriptionHtml: p.frontmatter.description,
|
||||
descriptionHtml: p.frontmatter.description,
|
||||
applicationHtml: '',
|
||||
featuredImage: p.frontmatter.images[0] || null,
|
||||
images: p.frontmatter.images,
|
||||
categories: p.frontmatter.categories.map(name => ({ name })),
|
||||
attributes: excelData?.attributes || [],
|
||||
};
|
||||
});
|
||||
|
||||
if (pdfProducts.length === 0) {
|
||||
console.warn(`! No products found for ${locale}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Render
|
||||
console.log(`- Rendering PDF with ${pdfProducts.length} pages...`);
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(PDFBrochure, {
|
||||
products: pdfProducts,
|
||||
locale,
|
||||
title: locale === 'de' ? 'Produktkatalog' : 'Product Catalog',
|
||||
subtitle: '2026',
|
||||
})
|
||||
);
|
||||
|
||||
// 4. Save
|
||||
const fileName = `KLZ_Cables_Brochure_2026_${locale.toUpperCase()}.pdf`;
|
||||
const filePath = path.join(CONFIG.outputDir, fileName);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
console.log(`✓ Generated: ${fileName} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const start = Date.now();
|
||||
console.log('--- KLZ Brochure Generator ---');
|
||||
|
||||
try {
|
||||
await ensureOutputDir();
|
||||
preloadExcelData();
|
||||
|
||||
for (const locale of CONFIG.locales) {
|
||||
await generateBrochure(locale);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Brochure generation completed in ${((Date.now() - start) / 1000).toFixed(2)}s`);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Fatal error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -139,7 +139,7 @@ async function main() {
|
||||
|
||||
// Using a more robust way to execute and capture output
|
||||
// We use a puppeteer script to set cookies which is more reliable than extraHeaders for LHCI
|
||||
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.puppeteerScript="scripts/lhci-puppeteer-setup.js" --collect.settings.chromeFlags="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" && npx lhci assert --config=config/lighthouserc.json`;
|
||||
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.puppeteerScript="scripts/lhci-puppeteer-setup.cjs" --collect.settings.chromeFlags="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" && npx lhci assert`;
|
||||
|
||||
console.log(`💻 Executing LHCI with CHROME_PATH="${chromePath}" and Puppeteer Auth...`);
|
||||
|
||||
|
||||
@@ -7,21 +7,21 @@ describe('Task 6 Content Updates', () => {
|
||||
const enHomePath = path.join(process.cwd(), 'content', 'en', 'home.mdx');
|
||||
const deKompetenzenPath = path.join(process.cwd(), 'content', 'de', 'kompetenzen.mdx');
|
||||
const enKompetenzenPath = path.join(process.cwd(), 'content', 'en', 'kompetenzen.mdx');
|
||||
const deKabeltiefbauPath = path.join(process.cwd(), 'content', 'de', 'kabeltiefbau.mdx');
|
||||
const enKabeltiefbauPath = path.join(process.cwd(), 'content', 'en', 'kabeltiefbau.mdx');
|
||||
const deKabelnetzbauPath = path.join(process.cwd(), 'content', 'de', 'kabeltiefbau.mdx');
|
||||
const enKabelnetzbauPath = path.join(process.cwd(), 'content', 'en', 'kabeltiefbau.mdx');
|
||||
const deUeberUnsPath = path.join(process.cwd(), 'content', 'de', 'ueber-uns.mdx');
|
||||
const enUeberUnsPath = path.join(process.cwd(), 'content', 'en', 'ueber-uns.mdx');
|
||||
|
||||
it('should verify Kabelleitungstiefbau changes in DE and EN kompetenzen.mdx', () => {
|
||||
it('should verify Kabelleitungsnetzbau changes in DE and EN kompetenzen.mdx', () => {
|
||||
const deContent = fs.readFileSync(deKompetenzenPath, 'utf8');
|
||||
const enContent = fs.readFileSync(enKompetenzenPath, 'utf8');
|
||||
|
||||
// DE Kompetenzen
|
||||
expect(deContent).toContain('title: "Kabelleitungstiefbau"');
|
||||
expect(deContent).toContain('title: "Kabelleitungsnetzbau"');
|
||||
expect(deContent).toContain('description: "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV."');
|
||||
expect(deContent).toContain('title: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung)"');
|
||||
expect(deContent).toContain('title: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung)"');
|
||||
expect(deContent).toContain('"Kabelmontagen bis 110 kV"');
|
||||
expect(deContent).toContain('"name": "Kabelleitungstiefbau"');
|
||||
expect(deContent).toContain('"name": "Kabelleitungsnetzbau"');
|
||||
expect(deContent).toContain('"description": "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV."');
|
||||
|
||||
// EN Competencies
|
||||
@@ -33,13 +33,13 @@ describe('Task 6 Content Updates', () => {
|
||||
expect(enContent).toContain('"description": "Classic trench construction, professional laying and cable assembly (high, medium, and low voltage) up to 110 kV."');
|
||||
});
|
||||
|
||||
it('should verify Kabelleitungstiefbau changes in DE and EN home.mdx', () => {
|
||||
it('should verify Kabelleitungsnetzbau changes in DE and EN home.mdx', () => {
|
||||
const deContent = fs.readFileSync(deHomePath, 'utf8');
|
||||
const enContent = fs.readFileSync(enHomePath, 'utf8');
|
||||
|
||||
// DE Home
|
||||
expect(deContent).toContain('title: "Kabelleitungstiefbau"');
|
||||
expect(deContent).toContain('description: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV"');
|
||||
expect(deContent).toContain('title: "Kabelleitungsnetzbau"');
|
||||
expect(deContent).toContain('description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV"');
|
||||
expect(deContent).toContain('100+');
|
||||
expect(deContent).toContain('Mitarbeitende');
|
||||
|
||||
@@ -50,18 +50,18 @@ describe('Task 6 Content Updates', () => {
|
||||
expect(enContent).toContain('Employees');
|
||||
});
|
||||
|
||||
it('should verify Kabelleitungstiefbau changes in DE and EN kabeltiefbau.mdx', () => {
|
||||
const deContent = fs.readFileSync(deKabeltiefbauPath, 'utf8');
|
||||
const enContent = fs.readFileSync(enKabeltiefbauPath, 'utf8');
|
||||
it('should verify Kabelleitungsnetzbau changes in DE and EN kabeltiefbau.mdx', () => {
|
||||
const deContent = fs.readFileSync(deKabelnetzbauPath, 'utf8');
|
||||
const enContent = fs.readFileSync(enKabelnetzbauPath, 'utf8');
|
||||
|
||||
// DE Kabeltiefbau
|
||||
expect(deContent).toContain('title: "Kabelleitungstiefbau"');
|
||||
expect(deContent).toContain('excerpt: "Professioneller Kabelleitungstiefbau');
|
||||
// DE Kabelnetzbau
|
||||
expect(deContent).toContain('title: "Kabelleitungsnetzbau"');
|
||||
expect(deContent).toContain('excerpt: "Professioneller Kabelleitungsnetzbau');
|
||||
expect(deContent).toContain('Kabelmontagen bis 110 kV');
|
||||
expect(deContent).toContain('subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"');
|
||||
expect(deContent).toContain('"Kabelmontagen bis 110 kV"');
|
||||
|
||||
// EN Kabeltiefbau
|
||||
// EN Kabelnetzbau
|
||||
expect(enContent).toContain('title: "Cable Civil Engineering"');
|
||||
expect(enContent).toContain('excerpt: "Professional cable civil engineering');
|
||||
expect(enContent).toContain('cable assembly up to 110 kV');
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('E2E Navigation & Content (MDX Architecture)', () => {
|
||||
it('should verify blog listing and post loading', async () => {
|
||||
await page.goto(`${BASE_URL}/de/blog`, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
let bodyText = await page.evaluate(() => document.body.innerText);
|
||||
expect(bodyText).toContain('Moderne Verfahren im Kabeltiefbau');
|
||||
expect(bodyText).toContain('Moderne Verfahren im Kabelnetzbau');
|
||||
|
||||
// Click on the first article (if found) or navigate directly
|
||||
await page.goto(`${BASE_URL}/de/blog/moderne-verfahren-kabeltiefbau`, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
|
||||
247
venv/bin/Activate.ps1
Normal file
247
venv/bin/Activate.ps1
Normal file
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
63
venv/bin/activate
Normal file
63
venv/bin/activate
Normal file
@@ -0,0 +1,63 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# you cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
VIRTUAL_ENV="/Volumes/Alpha SSD/Coding/e-tib.com/venv"
|
||||
export VIRTUAL_ENV
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1="(venv) ${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT="(venv) "
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
26
venv/bin/activate.csh
Normal file
26
venv/bin/activate.csh
Normal file
@@ -0,0 +1,26 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV "/Volumes/Alpha SSD/Coding/e-tib.com/venv"
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = "(venv) $prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT "(venv) "
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
69
venv/bin/activate.fish
Normal file
69
venv/bin/activate.fish
Normal file
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/); you cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV "/Volumes/Alpha SSD/Coding/e-tib.com/venv"
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) "(venv) " (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT "(venv) "
|
||||
end
|
||||
10
venv/bin/pip
Executable file
10
venv/bin/pip
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
'''exec' "/Volumes/Alpha SSD/Coding/e-tib.com/venv/bin/python3" "$0" "$@"
|
||||
' '''
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user