Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
490ce4abb6 | ||
|
|
bed9be1f81 | ||
|
|
f52a3e3c46 | ||
|
|
71825724db | ||
|
|
1bb26e8db4 | ||
|
|
604b96afe0 | ||
|
|
59df260164 | ||
|
|
3449d2eaa4 | ||
|
|
1723be4008 |
+3
-3
@@ -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'"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"https://e-tib.com/en": "https://storage.googleapis.com/lighthouse-infrastructure.appspot.com/reports/1782063743188-8000.report.html"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const rawText = await req.text();
|
||||
// Sentry sends NDJSON (Newline Delimited JSON). Split by newline to parse safely.
|
||||
const items = rawText.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
console.log("CLIENT ERROR INTERCEPTED:", JSON.stringify(items[0], null, 2));
|
||||
} catch (e) {
|
||||
console.log("Failed to parse relay body (NDJSON)", e);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
+6
-11
@@ -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,23 @@ 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' });
|
||||
|
||||
let pageTitle = t('title');
|
||||
if (pageTitle.startsWith('E-TIB GmbH | ')) {
|
||||
pageTitle = pageTitle.substring('E-TIB GmbH | '.length);
|
||||
}
|
||||
|
||||
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"
|
||||
description={pageTitle}
|
||||
label="Kabelnetzbau & Infrastruktur"
|
||||
image={bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
+14
-10
@@ -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 components used in MDX
|
||||
import nextDynamic from 'next/dynamic';
|
||||
|
||||
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,10 @@ const mdxComponents = (references: any[]) => ({
|
||||
AnimatedCounter,
|
||||
InteractiveGermanyMap,
|
||||
ScaleOfImpact,
|
||||
img: (props: any) => {
|
||||
// We proxy it through Next.js image optimizer
|
||||
return <img {...props} src={`/_next/image?url=${encodeURIComponent(props.src)}&w=1920&q=75`} loading="lazy" decoding="async" />;
|
||||
}
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -9,27 +9,28 @@ export async function getAnnotatorAssets() {
|
||||
publicDir = path.join(process.cwd(), 'public');
|
||||
}
|
||||
|
||||
const walk = (dir: string): string[] => {
|
||||
const walk = async (dir: string): Promise<string[]> => {
|
||||
let results: string[] = [];
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
const list = fs.readdirSync(dir);
|
||||
list.forEach((file) => {
|
||||
|
||||
const list = await fs.promises.readdir(dir);
|
||||
for (const file of list) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(walk(filePath));
|
||||
const nested = await walk(filePath);
|
||||
results = results.concat(nested);
|
||||
} else {
|
||||
if (filePath.match(/\.(png|jpg|jpeg|webp|mp4|svg)$/i)) {
|
||||
results.push(filePath.replace(publicDir, ''));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return results;
|
||||
};
|
||||
const results = walk(publicDir);
|
||||
console.log('[Annotator] publicDir:', publicDir);
|
||||
console.log('[Annotator] Assets found:', results.length);
|
||||
console.log('[Annotator] First 5 paths:', results.slice(0, 5));
|
||||
|
||||
const results = await walk(publicDir);
|
||||
|
||||
// Console logs removed for absolute silence & speed
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function TestPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>TEST PAGE WORKS</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { Annotator } from '@mintel/annotator';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getAnnotatorAssets } from '@/app/actions/getAnnotatorAssets';
|
||||
import { submitAnnotations } from '@/app/actions/submitAnnotations';
|
||||
|
||||
const Annotator = dynamic(() => import('@/components/annotator-local/Annotator').then(mod => mod.Annotator), { ssr: false });
|
||||
|
||||
export default function AnnotatorClientWrapper() {
|
||||
const [assets, setAssets] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getAnnotatorAssets().then(fetchedAssets => {
|
||||
console.log('[AnnotatorClientWrapper] fetchedAssets:', fetchedAssets);
|
||||
getAnnotatorAssets()
|
||||
.then(fetchedAssets => {
|
||||
if (Array.isArray(fetchedAssets)) {
|
||||
setAssets(fetchedAssets);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[AnnotatorClientWrapper] Action failed:', err);
|
||||
});
|
||||
})
|
||||
.catch(err => console.error('[AnnotatorClientWrapper] Fetch failed:', err));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (annotations: any[]) => {
|
||||
|
||||
@@ -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,27 +76,40 @@ 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 }}>
|
||||
{/* Main Content Wrapper */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
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: accentGreen,
|
||||
color: primaryGreen,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.3em',
|
||||
marginBottom: '32px',
|
||||
letterSpacing: '0.25em',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
@@ -106,12 +120,12 @@ export function OGImageTemplate({
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: title.length > 40 ? '64px' : '82px',
|
||||
fontSize: '72px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
lineHeight: '1.05',
|
||||
maxWidth: '950px',
|
||||
marginBottom: '40px',
|
||||
lineHeight: '1.1',
|
||||
maxWidth: '900px',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
@@ -123,38 +137,49 @@ export function OGImageTemplate({
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: '36px',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
maxWidth: '850px',
|
||||
lineHeight: '1.4',
|
||||
display: 'flex',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand Footer */}
|
||||
{/* Bottom: Brand Footer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '80px',
|
||||
left: '80px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{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: accentGreen,
|
||||
backgroundColor: primaryGreen,
|
||||
borderRadius: '3px',
|
||||
marginRight: '24px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!logo && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
@@ -165,11 +190,13 @@ export function OGImageTemplate({
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
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>
|
||||
|
||||
+5
-33
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { finder } from "@medv/finder";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
selector: string;
|
||||
type: "text" | "image" | "note";
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
url: string;
|
||||
elementRect: Record<string, number> | null;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AnnotatorProps {
|
||||
assets?: string[]; // Array of image URLs for the media browser
|
||||
onSubmit?: (annotations: Annotation[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("mintel-annotations");
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved annotations", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("mintel-annotations", JSON.stringify(annotations));
|
||||
}
|
||||
}, [annotations]);
|
||||
|
||||
// Modal state
|
||||
const [currentType, setCurrentType] = useState<"text" | "image" | "note">("text");
|
||||
const [currentText, setCurrentText] = useState("");
|
||||
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||
const [showList, setShowList] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Disable overlays if needed (e.g. inside iframes)
|
||||
const isExcluded = useMemo(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.self !== window.top;
|
||||
}, []);
|
||||
|
||||
const getSelector = (el: HTMLElement): string => {
|
||||
try {
|
||||
return finder(el, {
|
||||
root: document.body,
|
||||
className: (name) =>
|
||||
!name.startsWith("annotator-") &&
|
||||
!name.includes("[") &&
|
||||
!name.includes("/") &&
|
||||
!name.match(/^[a-z]-[0-9]/),
|
||||
idName: (name) => !name.startsWith("__next") && !name.includes(":"),
|
||||
});
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".annotator-ui-ignore")) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
setHoveredElement(target);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".annotator-ui-ignore")) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setSelectedElement(target);
|
||||
setHoveredElement(null);
|
||||
|
||||
// Auto-detect type
|
||||
if (target.tagName.toLowerCase() === "img") {
|
||||
setCurrentType("image");
|
||||
} else {
|
||||
setCurrentType("text");
|
||||
setCurrentText(target.innerText || "");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("click", handleClick, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (selectedElement) {
|
||||
setSelectedElement(null);
|
||||
} else if (isActive) {
|
||||
setIsActive(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
const saveAnnotation = () => {
|
||||
if (!selectedElement) return;
|
||||
|
||||
const rect = selectedElement.getBoundingClientRect();
|
||||
const newContent = currentType === "image" ? selectedAsset || "" : currentText;
|
||||
|
||||
if (!newContent) return;
|
||||
|
||||
const annotation: Annotation = {
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
x: rect.left + rect.width / 2 + window.scrollX,
|
||||
y: rect.top + rect.height / 2 + window.scrollY,
|
||||
selector: getSelector(selectedElement),
|
||||
type: currentType,
|
||||
originalContent: currentType === "image" ? (selectedElement as HTMLImageElement).src || "" : selectedElement.innerText,
|
||||
newContent,
|
||||
url: window.location.href,
|
||||
elementRect: rect ? {
|
||||
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
|
||||
top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left
|
||||
} : null,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setAnnotations([...annotations, annotation]);
|
||||
setSelectedElement(null);
|
||||
setCurrentText("");
|
||||
setSelectedAsset(null);
|
||||
};
|
||||
|
||||
const exportJSON = () => {
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(annotations, null, 2));
|
||||
const downloadAnchorNode = document.createElement("a");
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute("download", "corrections.json");
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!onSubmit || annotations.length === 0) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(annotations);
|
||||
setAnnotations([]);
|
||||
setShowList(false);
|
||||
} catch (error) {
|
||||
console.error("Submission failed", error);
|
||||
alert("Fehler beim Senden der Korrekturen. Bitte JSON Export nutzen.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnnotation = (id: string) => {
|
||||
setAnnotations(annotations.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const [updateTick, setUpdateTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive && !selectedElement) return;
|
||||
|
||||
let rafId: number;
|
||||
const handleScroll = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => setUpdateTick(t => t + 1));
|
||||
};
|
||||
|
||||
// Use capture phase to catch scrolls on any nested container
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
const hoveredRect = useMemo(() => hoveredElement?.getBoundingClientRect(), [hoveredElement, updateTick]);
|
||||
const selectedRect = useMemo(() => selectedElement?.getBoundingClientRect(), [selectedElement, updateTick]);
|
||||
|
||||
if (isExcluded) return null;
|
||||
|
||||
return (
|
||||
<div className="annotator-ui-ignore">
|
||||
{/* 1. Global Toolbar */}
|
||||
<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(
|
||||
"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={20} /> : <MessageSquare size={20} />}
|
||||
</button>
|
||||
|
||||
<div className="w-6 h-px bg-white/10 my-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowList(!showList)}
|
||||
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-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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<>
|
||||
<div className="w-6 h-px bg-white/10 my-1" />
|
||||
<button
|
||||
onClick={exportJSON}
|
||||
className="p-2.5 rounded-xl text-white/70 hover:text-white hover:bg-white/10 transition-all"
|
||||
title="JSON Export"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Highlights */}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<div className="fixed inset-0 pointer-events-none z-[9998]">
|
||||
{hoveredRect && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
|
||||
style={{
|
||||
top: hoveredRect.top,
|
||||
left: hoveredRect.left,
|
||||
width: hoveredRect.width,
|
||||
height: hoveredRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedRect && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
|
||||
style={{
|
||||
top: selectedRect.top,
|
||||
left: selectedRect.left,
|
||||
width: selectedRect.width,
|
||||
height: selectedRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Action Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedElement && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-4 sm:p-6 w-[95vw] md:w-[800px] shadow-2xl flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white font-bold text-lg">Änderung erfassen</h3>
|
||||
<button
|
||||
onClick={() => setSelectedElement(null)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6 shrink-0">
|
||||
<button
|
||||
onClick={() => setCurrentType("text")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "text"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<Type size={16} /> Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentType("image")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "image"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<ImageIcon size={16} /> Bild
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentType("note")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "note"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<StickyNote size={16} /> Notiz
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-[200px] mb-6 hide-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{currentType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neuer Text</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={currentText}
|
||||
onChange={(e) => setCurrentText(e.target.value)}
|
||||
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentType === "note" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Anmerkung</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={currentText}
|
||||
onChange={(e) => setCurrentText(e.target.value)}
|
||||
placeholder="Was soll hier gemacht werden?"
|
||||
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentType === "image" && (
|
||||
<div className="space-y-2 h-full">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neues Bild wählen</label>
|
||||
{assets.length === 0 ? (
|
||||
<div className="text-sm text-white/40 p-4 bg-white/5 rounded-xl border border-white/5 text-center">
|
||||
Keine Assets übergeben. Bitte das prop <code>assets</code> befüllen.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<style>{`.hide-scrollbar::-webkit-scrollbar { display: none; }`}</style>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen nach Dateinamen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 h-[400px] overflow-y-auto hide-scrollbar"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{assets.filter(asset => asset.toLowerCase().includes(searchTerm.toLowerCase())).map((asset) => {
|
||||
const filename = asset.split('/').pop() || asset;
|
||||
return (
|
||||
<button
|
||||
key={asset}
|
||||
title={asset}
|
||||
onClick={() => setSelectedAsset(asset)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center bg-black/20 rounded-xl overflow-hidden border-2 transition-all p-2 gap-2 w-full",
|
||||
selectedAsset === asset
|
||||
? "border-blue-500 shadow-[0_0_0_4px_rgba(59,130,246,0.3)] bg-blue-500/10"
|
||||
: "border-transparent hover:border-white/20 hover:bg-white/5"
|
||||
)}
|
||||
style={{ height: '130px' }}
|
||||
>
|
||||
<div className="w-full flex-1 rounded-lg overflow-hidden bg-black/40 flex items-center justify-center relative">
|
||||
{asset.match(/\.(mp4|webm)$/i) ? (
|
||||
<video src={asset} className="w-full h-full object-cover opacity-80" />
|
||||
) : (
|
||||
<img src={asset} loading="lazy" alt={filename} className="w-full h-full object-contain" />
|
||||
)}
|
||||
{selectedAsset === asset && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/20 backdrop-blur-[2px]">
|
||||
<div className="bg-blue-500 text-white rounded-full p-1.5 shadow-lg">
|
||||
<Check size={18} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-white/70 truncate w-full text-center shrink-0" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={(!currentText && currentType !== "image") || (currentType === "image" && !selectedAsset)}
|
||||
onClick={saveAnnotation}
|
||||
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 shrink-0"
|
||||
>
|
||||
<Check size={20} />
|
||||
Speichern
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 4. Annotations List Sidebar */}
|
||||
<AnimatePresence>
|
||||
{showList && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowList(false)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed top-0 right-0 h-full w-full sm:w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="p-8 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Korrekturen</h2>
|
||||
<p className="text-white/40 text-sm">{annotations.length} erfasst</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowList(false)}
|
||||
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{annotations.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
|
||||
<List size={48} className="mb-4" />
|
||||
<p>Noch keine Korrekturen vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
annotations.map((ann) => (
|
||||
<div
|
||||
key={ann.id}
|
||||
className="bg-white/5 border border-white/5 rounded-2xl p-4 flex flex-col gap-3 group relative"
|
||||
>
|
||||
<button
|
||||
onClick={() => deleteAnnotation(ann.id)}
|
||||
className="absolute top-4 right-4 text-white/20 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider flex items-center gap-1",
|
||||
ann.type === "text" ? "bg-blue-500/20 text-blue-400" :
|
||||
ann.type === "image" ? "bg-purple-500/20 text-purple-400" :
|
||||
"bg-orange-500/20 text-orange-400"
|
||||
)}>
|
||||
{ann.type === "text" && <Type size={12} />}
|
||||
{ann.type === "image" && <ImageIcon size={12} />}
|
||||
{ann.type === "note" && <StickyNote size={12} />}
|
||||
{ann.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ann.type === "image" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={ann.newContent} alt="New" className="w-16 h-16 object-cover rounded-lg bg-white/10" />
|
||||
<div className="text-xs text-white/60 flex-1 truncate">{ann.newContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white text-sm whitespace-pre-wrap">{ann.newContent}</p>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] text-white/30 truncate mt-2 font-mono flex items-center gap-2">
|
||||
<span>{ann.selector}</span>
|
||||
{ann.url && <span className="truncate max-w-[100px] text-blue-400" title={ann.url}>{new URL(ann.url).pathname}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2 border-t border-white/5">
|
||||
{ann.timestamp && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
{new Date(ann.timestamp).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{ann.viewportWidth && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
{ann.viewportWidth}x{ann.viewportHeight}
|
||||
</span>
|
||||
)}
|
||||
{ann.scrollX !== undefined && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
Scroll: {Math.round(ann.scrollX)},{Math.round(ann.scrollY)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<div className="p-6 border-t border-white/10 flex flex-col gap-3">
|
||||
{onSubmit && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-blue-500 hover:bg-blue-400 text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Check size={20} />
|
||||
)}
|
||||
{isSubmitting ? "Wird gesendet..." : "Korrekturen an Entwickler senden"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={exportJSON}
|
||||
className={cn(
|
||||
"w-full font-bold py-3 rounded-2xl flex items-center justify-center gap-2 transition-all",
|
||||
onSubmit ? "bg-white/5 text-white/70 hover:bg-white/10 hover:text-white" : "bg-blue-500 hover:bg-blue-400 text-white shadow-lg shadow-blue-500/20 py-4"
|
||||
)}
|
||||
>
|
||||
<Download size={onSubmit ? 18 : 20} />
|
||||
JSON {onSubmit && "lokal "}Exportieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Möchtest du wirklich alle erfassten Korrekturen löschen?")) {
|
||||
setAnnotations([]);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-red-500/10 text-red-500 hover:bg-red-500/20 font-bold py-3 rounded-xl flex items-center justify-center gap-2 transition-all mt-2"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Alle löschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Annotator } from "./Annotator";
|
||||
@@ -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,32 +41,61 @@ 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 ? (
|
||||
<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}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
) : (
|
||||
{/* 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"
|
||||
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-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||
src={videoSrcLoaded ? videoUrl : undefined}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cinematic Color Grading Overlay */}
|
||||
@@ -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>
|
||||
)})}
|
||||
|
||||
{/* 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
|
||||
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
|
||||
<MajorNode
|
||||
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" />
|
||||
)}
|
||||
loc={loc}
|
||||
idx={idx}
|
||||
isActive={activeLocation?.id === loc.id}
|
||||
onEnter={handleMouseEnter}
|
||||
onLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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]);
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<>
|
||||
<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) => {
|
||||
// Determine if active
|
||||
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${item.url}`
|
||||
: item.url;
|
||||
|
||||
const isActive = item.exactMatch
|
||||
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-2xl shadow-sm border border-primary/10"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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}
|
||||
className={`relative flex flex-col items-center justify-center w-full min-h-[44px] py-1 transition-colors duration-300 select-none ${
|
||||
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' }}
|
||||
>
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
{/* Active Icon Background */}
|
||||
{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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon wrapper to ensure it sits above the background */}
|
||||
<div className="relative z-10 transition-transform duration-300 active:scale-90">
|
||||
{item.icon}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold mt-0.5 tracking-wide z-10">{item.label}</span>
|
||||
{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>
|
||||
</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;
|
||||
@@ -60,7 +46,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
setTimeout(() => {
|
||||
router.push(href);
|
||||
|
||||
// Fallback: If for some reason the route doesn't change after 5 seconds, open the shutter anyway
|
||||
// Fallback: If for some reason the route doesn't change after 60 seconds (useful for slow dev servers), open the shutter anyway
|
||||
setTimeout(() => {
|
||||
setIsTransitioning((current) => {
|
||||
if (current) {
|
||||
@@ -69,17 +55,41 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, 5000);
|
||||
}, 60000);
|
||||
}, 700);
|
||||
}, [isTransitioning, router]);
|
||||
|
||||
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" />
|
||||
|
||||
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"
|
||||
---
|
||||
|
||||
|
||||
+7
-7
@@ -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="/_next/image?url=%2Fassets%2Fphotos%2FEtib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg&w=1920&q=75" alt="E-TIB im Einsatz" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
||||
<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/kabelnetzbau",
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Kabelleitungstiefbau" }
|
||||
href: "/de/kabeltiefbau",
|
||||
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/kabelnetzbau",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungstiefbau" }
|
||||
href: "/de/kabeltiefbau",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||
},
|
||||
{
|
||||
title: "Bohrtechnik",
|
||||
@@ -36,7 +36,7 @@ layout: "fullBleed"
|
||||
tag: "Innovation",
|
||||
size: "medium",
|
||||
href: "/de/bohrtechnik",
|
||||
image: { url: "/assets/photos/DSC08653.JPG", alt: "Bohrtechnik" }
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-47.jpg", alt: "Bohrtechnik" }
|
||||
},
|
||||
{
|
||||
title: "Glasfaser",
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+8
-8
@@ -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="/_next/image?url=%2Fassets%2Fphotos%2FEtib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg&w=1920&q=75" alt="E-TIB in action" className="w-full h-full object-cover" loading="lazy" decoding="async" />
|
||||
<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">
|
||||
@@ -121,8 +121,8 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
|
||||
description: "Cable civil engineering (high, medium, low voltage) and cable assembly up to 110 kV",
|
||||
tag: "Energy",
|
||||
size: "large",
|
||||
href: "/en/cable-civil-engineering",
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Cable Civil Engineering" }
|
||||
href: "/en/kabeltiefbau",
|
||||
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"
|
||||
---
|
||||
|
||||
@@ -27,8 +27,8 @@ layout: "fullBleed"
|
||||
description: "Classic trench construction, professional laying and cable assembly (high, medium, and low voltage) up to 110 kV.",
|
||||
tag: "Energy",
|
||||
size: "large",
|
||||
href: "/en/cable-civil-engineering",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Civil Engineering" }
|
||||
href: "/en/kabeltiefbau",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
|
||||
},
|
||||
{
|
||||
title: "Drilling Technology",
|
||||
@@ -36,7 +36,7 @@ layout: "fullBleed"
|
||||
tag: "Innovation",
|
||||
size: "medium",
|
||||
href: "/en/drilling-technology",
|
||||
image: { url: "/assets/photos/DSC08653.JPG", alt: "Drilling Technology" }
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-47.jpg", alt: "Drilling Technology" }
|
||||
},
|
||||
{
|
||||
title: "Fiber Optics",
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
+36
-3
@@ -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
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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.',
|
||||
|
||||
@@ -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")
|
||||
+12
-12
@@ -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": {
|
||||
|
||||
Binary file not shown.
@@ -15,6 +15,10 @@ const nextConfig = {
|
||||
pagesBufferLength: 2,
|
||||
},
|
||||
experimental: {
|
||||
staleTimes: {
|
||||
dynamic: 0,
|
||||
static: 30,
|
||||
},
|
||||
optimizePackageImports: [
|
||||
'lucide-react',
|
||||
'framer-motion',
|
||||
|
||||
+2
-9
@@ -4,14 +4,13 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"dependencies": {
|
||||
"@mintel/annotator": "file:./mintel-annotator-1.0.7.tgz",
|
||||
"@medv/finder": "^4.0.2",
|
||||
"@mintel/mail": "1.9.5",
|
||||
"@mintel/next-config": "1.9.5",
|
||||
"@mintel/next-feedback": "1.9.5",
|
||||
"@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",
|
||||
@@ -26,13 +25,11 @@
|
||||
"ioredis": "^5.9.3",
|
||||
"jsdom": "^27.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"mintel-annotator": "link:mintel-annotator",
|
||||
"next": "16.1.6",
|
||||
"next-i18next": "^15.4.3",
|
||||
"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",
|
||||
@@ -125,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",
|
||||
@@ -145,7 +138,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.2.13-rc.43",
|
||||
"version": "2.2.30-rc.8",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
Generated
+3
-380
@@ -12,9 +12,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@mintel/annotator':
|
||||
specifier: file:./mintel-annotator-1.0.7.tgz
|
||||
version: file:mintel-annotator-1.0.7.tgz(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@medv/finder':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
'@mintel/mail':
|
||||
specifier: 1.9.5
|
||||
version: 1.9.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -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)
|
||||
@@ -78,9 +75,6 @@ importers:
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
mintel-annotator:
|
||||
specifier: link:mintel-annotator
|
||||
version: link:mintel-annotator
|
||||
next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)
|
||||
@@ -96,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
|
||||
@@ -1407,13 +1398,6 @@ packages:
|
||||
'@medv/finder@4.0.2':
|
||||
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
||||
|
||||
'@mintel/annotator@file:mintel-annotator-1.0.7.tgz':
|
||||
resolution: {integrity: sha512-At2+o4CxJBBK82Cl0xC/acAbs5RrNxnwDbuAGhmxMHGjL5Qero3TKxIvqI56FOgdZpdALfA2CT8aUpTsB0Vt+w==, tarball: file:mintel-annotator-1.0.7.tgz}
|
||||
version: 1.0.7
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@mintel/eslint-config@1.9.11':
|
||||
resolution: {integrity: sha512-g0FfWbSjfNuKAWa9yd4eC02mr9db66Y71TZ+FN3aUrLLMjwn3qqtEHez+8Dbray0loLiGDkm75EI7N0+mgA9yA==, tarball: https://git.infra.mintel.me/api/packages/mmintel/npm/%40mintel%2Feslint-config/-/1.9.11/eslint-config-1.9.11.tgz}
|
||||
|
||||
@@ -1907,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==}
|
||||
|
||||
@@ -2231,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:
|
||||
@@ -3448,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'}
|
||||
@@ -3786,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}
|
||||
@@ -3983,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==}
|
||||
|
||||
@@ -4122,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'}
|
||||
@@ -4440,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==}
|
||||
|
||||
@@ -4951,20 +4871,6 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
framer-motion@12.40.0:
|
||||
resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fresh@0.5.2:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -5194,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}
|
||||
@@ -5281,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==}
|
||||
|
||||
@@ -5417,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'}
|
||||
@@ -5578,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'}
|
||||
@@ -5622,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'}
|
||||
@@ -5958,11 +5846,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
lucide-react@1.21.0:
|
||||
resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
@@ -6063,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'}
|
||||
@@ -6268,18 +6148,12 @@ packages:
|
||||
motion-dom@12.38.0:
|
||||
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
||||
|
||||
motion-dom@12.40.0:
|
||||
resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
|
||||
|
||||
motion-utils@11.18.1:
|
||||
resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==}
|
||||
|
||||
motion-utils@12.36.0:
|
||||
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
|
||||
|
||||
motion-utils@12.39.0:
|
||||
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -6418,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'}
|
||||
@@ -6567,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'}
|
||||
@@ -6588,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==}
|
||||
|
||||
@@ -6642,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'}
|
||||
@@ -6861,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==}
|
||||
|
||||
@@ -7157,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==}
|
||||
|
||||
@@ -7257,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'}
|
||||
@@ -7436,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==}
|
||||
|
||||
@@ -7524,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==}
|
||||
|
||||
@@ -7543,9 +7390,6 @@ packages:
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
tailwind-merge@3.6.0:
|
||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||
|
||||
tailwindcss@4.2.2:
|
||||
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
|
||||
|
||||
@@ -7913,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}
|
||||
@@ -8255,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'}
|
||||
@@ -9358,18 +9195,6 @@ snapshots:
|
||||
|
||||
'@medv/finder@4.0.2': {}
|
||||
|
||||
'@mintel/annotator@file:mintel-annotator-1.0.7.tgz(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@medv/finder': 4.0.2
|
||||
clsx: 2.1.1
|
||||
framer-motion: 12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
lucide-react: 1.21.0(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
tailwind-merge: 3.6.0
|
||||
transitivePeerDependencies:
|
||||
- '@emotion/is-prop-valid'
|
||||
|
||||
'@mintel/eslint-config@1.9.11(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint/eslintrc': 3.3.5
|
||||
@@ -9913,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':
|
||||
@@ -10200,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
|
||||
@@ -11479,8 +11195,6 @@ snapshots:
|
||||
|
||||
abbrev@2.0.0: {}
|
||||
|
||||
abs-svg-path@0.1.1: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
@@ -11801,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
|
||||
@@ -12019,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:
|
||||
@@ -12170,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: {}
|
||||
@@ -12503,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: {}
|
||||
@@ -13258,15 +12959,6 @@ snapshots:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
framer-motion@12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.40.0
|
||||
motion-utils: 12.39.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
||||
from@0.1.7: {}
|
||||
@@ -13575,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)
|
||||
@@ -13679,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):
|
||||
@@ -13832,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
|
||||
@@ -13971,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:
|
||||
@@ -14023,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
|
||||
@@ -14376,10 +14052,6 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
lucide-react@1.21.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
maath@0.10.8(@types/three@0.183.1)(three@0.183.2):
|
||||
@@ -14579,8 +14251,6 @@ snapshots:
|
||||
|
||||
mdn-data@2.27.1: {}
|
||||
|
||||
media-engine@1.0.3: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
meow@13.2.0: {}
|
||||
@@ -14915,16 +14585,10 @@ snapshots:
|
||||
dependencies:
|
||||
motion-utils: 12.36.0
|
||||
|
||||
motion-dom@12.40.0:
|
||||
dependencies:
|
||||
motion-utils: 12.39.0
|
||||
|
||||
motion-utils@11.18.1: {}
|
||||
|
||||
motion-utils@12.36.0: {}
|
||||
|
||||
motion-utils@12.39.0: {}
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
@@ -15053,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
|
||||
@@ -15267,8 +14927,6 @@ snapshots:
|
||||
|
||||
pako@0.2.9: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -15296,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
|
||||
@@ -15348,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
|
||||
@@ -15604,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: {}
|
||||
@@ -16026,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: {}
|
||||
@@ -16180,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
|
||||
@@ -16420,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
|
||||
@@ -16494,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
|
||||
@@ -16508,8 +16141,6 @@ snapshots:
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwind-merge@3.6.0: {}
|
||||
|
||||
tailwindcss@4.2.2: {}
|
||||
|
||||
tapable@2.3.2: {}
|
||||
@@ -16966,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
|
||||
@@ -17303,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
|
||||
|
||||
@@ -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+).*)']
|
||||
};
|
||||
|
||||
Binary file not shown.
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
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user