Compare commits

...

14 Commits

Author SHA1 Message Date
f1dff452c5 2.4.16
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m32s
Build & Deploy / 🏗️ Build (push) Successful in 2m52s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 26s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-03 01:07:09 +02:00
e82b81bbde perf(layout): remove heavy CorporateBackground to eliminate main thread blocking 2026-07-03 01:07:09 +02:00
9377be51f2 2.4.15
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🧪 QA (push) Successful in 2m1s
Build & Deploy / 🏗️ Build (push) Successful in 3m13s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 55s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-03 01:03:11 +02:00
ca12c56782 perf(images): aggressively compress LCP poster and logo 2026-07-03 01:03:10 +02:00
3a8335be34 2.4.14
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🧪 QA (push) Successful in 1m36s
Build & Deploy / 🏗️ Build (push) Successful in 3m7s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m5s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-03 01:00:08 +02:00
52f9774478 perf(hero): split HeroVideo into a Server Component to bypass React Hydration LCP block 2026-07-03 01:00:07 +02:00
db6492207d 2.4.13
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 25s
Build & Deploy / 🏗️ Build (push) Successful in 3m13s
Build & Deploy / 🧪 QA (push) Successful in 1m26s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 57s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-03 00:54:35 +02:00
99ae6d90c2 perf(css): remove inlineCss:false to fix 300ms render-blocking delay 2026-07-03 00:54:35 +02:00
7d53ca5a11 2.4.12
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 55s
Build & Deploy / 🧪 QA (push) Successful in 1m30s
Build & Deploy / 🏗️ Build (push) Successful in 2m45s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-07-02 22:15:03 +02:00
0331f3f7ec fix(i18n): add missing Team.gallery translations & perf(hero): force eager LCP image 2026-07-02 22:15:03 +02:00
274d013047 2.4.11
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🏗️ Build (push) Successful in 2m42s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 56s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-07-02 22:10:45 +02:00
f1d0c9c5b2 perf: remove template.tsx wrapper to eliminate LCP hydration delay 2026-07-02 22:10:45 +02:00
c121fe0b93 2.4.10
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m27s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 2m14s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-07-02 22:03:38 +02:00
640a9dfc9f perf: remove framer-motion filter blur animations to fix main thread blocking, compress heavy images 2026-07-02 22:03:37 +02:00
161 changed files with 67643 additions and 3112 deletions

View File

@@ -20,8 +20,8 @@ import { TransitionProvider } from '@/components/providers/TransitionProvider';
import { DynamicInitialLoader as InitialLoader } from '@/components/providers/DynamicInitialLoader';
import { DynamicMobileBottomNav as MobileBottomNav } from '@/components/layout/DynamicMobileBottomNav';
import { DynamicCorporateBackground as CorporateBackground } from '@/components/decorations/DynamicCorporateBackground';
const corporateBackground = null; // Removed to prevent line shifting
// Removed CorporateBackground to improve LCP by eliminating Framer Motion main thread block
const corporateBackground = null;
const inter = Inter({
subsets: ['latin'],
@@ -190,8 +190,6 @@ export default async function Layout(props: {
<body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white" suppressHydrationWarning>
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<TransitionProvider>
<CorporateBackground />
{!hasSeenLoader && <InitialLoader shouldShowLoader={!hasSeenLoader} />}
{/* PageTransitionShutter loaded dynamically in TransitionProvider */}
<SkipLink />
<Header navLinks={navLinks} />

View File

@@ -0,0 +1,25 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
const fonts = await getOgFonts();
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
return new ImageResponse(
<OGImageTemplate title={title} description={description} label="Our Team" />,
{
...OG_IMAGE_SIZE,
fonts,
},
);
}

326
app/[locale]/team/page.tsx Normal file
View File

@@ -0,0 +1,326 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import { Section, Container, Heading, Badge } from '@/components/ui';
import Image from 'next/image';
import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';
import TrackedButton from '@/components/analytics/TrackedButton';
interface TeamPageProps {
params: Promise<{
locale: string;
}>;
}
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
return {
title,
description,
alternates: {
canonical: `${SITE_URL}/${locale}/team`,
languages: {
de: `${SITE_URL}/de/team`,
en: `${SITE_URL}/en/team`,
'x-default': `${SITE_URL}/en/team`,
},
},
openGraph: {
title: `${title} | E-TIB`,
description,
url: `${SITE_URL}/${locale}/team`,
},
twitter: {
card: 'summary_large_image',
title: `${title} | E-TIB`,
description,
},
};
}
export default async function TeamPage({ params }: TeamPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'Team' });
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
<JsonLd
id="breadcrumb-team"
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
/>
<JsonLd
id="person-michael"
data={{
'@context': 'https://schema.org',
'@type': 'Person',
name: t('michael.name'),
jobTitle: t('michael.role'),
worksFor: {
'@type': 'Organization',
name: 'E-TIB',
},
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
}}
/>
<JsonLd
id="person-klaus"
data={{
'@context': 'https://schema.org',
'@type': 'Person',
name: t('klaus.name'),
jobTitle: t('klaus.role'),
worksFor: {
'@type': 'Organization',
name: 'E-TIB',
},
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
}}
/>
{/* Hero Section */}
<Reveal>
<section className="relative flex items-center justify-center overflow-hidden bg-primary-dark pt-32 pb-24 md:pt-[14%] md:pb-[12%]">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/DSC07655-Large.webp"
alt="E-TIB Team"
fill
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
sizes="100vw"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
</div>
<Container className="relative z-10 text-center text-white max-w-5xl">
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
{t('hero.badge')}
</Badge>
<Heading level={1} className="text-white mb-4 md:mb-8">
{t('hero.subtitle')}
</Heading>
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
{t('hero.title')}
</p>
</Container>
</section>
</Reveal>
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
<div className="relative z-10">
<Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')}
</Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
<span className="text-white">{t('michael.name')}</span>
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
{t('michael.quote')}
</p>
</div>
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
{t('michael.description')}
</p>
<TrackedButton
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
variant="accent"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
eventProperties={{
type: 'social_linkedin',
person: 'Michael Bodemer',
location: 'team_page',
}}
>
{t('michael.linkedin')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</TrackedButton>
</div>
</Reveal>
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
<Image
src="/uploads/2024/12/DSC07768-Large.webp"
alt={t('michael.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
</Reveal>
</div>
</article>
{/* Legacy Section - Immersive Background */}
<Reveal>
<section className="relative py-16 md:py-48 bg-primary-dark text-white overflow-hidden">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/1694273920124-copy.webp"
alt={t('legacy.subtitle')}
fill
className="object-cover opacity-20 md:opacity-30 scale-110 animate-slow-zoom"
sizes="100vw"
/>
<div className="absolute inset-0 bg-primary-dark/60 mix-blend-multiply" />
</div>
<Container className="relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
<div className="lg:col-span-6">
<Heading
level={2}
subtitle={t('legacy.subtitle')}
className="text-white mb-6 md:mb-10"
>
<span className="text-white">{t('legacy.title')}</span>
</Heading>
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
{t('legacy.p1')}
</p>
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
</div>
</div>
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
{t('legacy.expertise')}
</div>
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
{t('legacy.expertiseDesc')}
</div>
</div>
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
{t('legacy.network')}
</div>
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
{t('legacy.networkDesc')}
</div>
</div>
</div>
</div>
</Container>
</section>
</Reveal>
{/* Klaus Mintel Section - Reversed Split Layout */}
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
<Image
src="/uploads/2024/12/DSC07963-Large.webp"
alt={t('klaus.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
</Reveal>
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
<div className="relative z-10">
<Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')}
</Badge>
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
{t('klaus.name')}
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
{t('klaus.quote')}
</p>
</div>
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
{t('klaus.description')}
</p>
<TrackedButton
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
variant="saturated"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
eventProperties={{
type: 'social_linkedin',
person: 'Klaus Mintel',
location: 'team_page',
}}
>
{t('klaus.linkedin')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</TrackedButton>
</div>
</Reveal>
</div>
</article>
{/* Manifesto Section - Modern Grid */}
<Section className="bg-white text-primary py-16 md:py-28">
<Container>
<div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
<div className="lg:sticky lg:top-32">
<Heading level={2} subtitle={t('manifesto.subtitle')} className="mb-4 md:mb-8">
{t('manifesto.title')}
</Heading>
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
{t('manifesto.tagline')}
</p>
{/* Mobile-only progress indicator */}
<div className="flex lg:hidden mt-8 gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden"
>
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
</div>
))}
</div>
</div>
</div>
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
{[0, 1, 2, 3, 4, 5].map((idx) => (
<li
key={idx}
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
>
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
0{idx + 1}
</span>
</div>
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
{t(`manifesto.items.${idx}.title`)}
</h3>
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
{t(`manifesto.items.${idx}.description`)}
</p>
</li>
))}
</ul>
</div>
</Container>
</Section>
<Reveal>
<Gallery />
</Reveal>
</div>
);
}

View File

@@ -1,19 +0,0 @@
'use client';
import { m } from 'framer-motion';
export default function Template({ children }: { children: React.ReactNode }) {
return (
<m.div
initial={{ opacity: 0, y: 20, filter: 'blur(10px)' }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
transition={{
duration: 0.7,
ease: [0.16, 1, 0.3, 1],
}}
className="w-full h-full"
>
{children}
</m.div>
);
}

102
app/actions/brochure.ts Normal file
View File

@@ -0,0 +1,102 @@
'use server';
import { getServerAppServices } from '@/lib/services/create-services.server';
export async function requestBrochureAction(formData: FormData) {
const services = getServerAppServices();
const logger = services.logger.child({ action: 'requestBrochureAction' });
const { headers } = await import('next/headers');
const requestHeaders = await headers();
if ('setServerContext' in services.analytics) {
(services.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
});
}
services.analytics.track('brochure-request-attempt');
const email = formData.get('email') as string;
const locale = (formData.get('locale') as string) || 'en';
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in brochure request', { email });
// Silently succeed to fool the bot without doing actual work
return { success: true };
}
if (!email) {
logger.warn('Missing email in brochure request');
return { success: false, error: 'Missing email address' };
}
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { success: false, error: 'Invalid email address' };
}
// 1. Save to CMS - Removed along with Payload CMS
// 2. Notify via Gotify
try {
await services.notifications.notify({
title: '📑 Brochure Download Request',
message: `New brochure download request from ${email} (${locale})`,
priority: 3,
});
} catch (error) {
logger.error('Failed to send notification', { error });
}
// 3. Send Brochure via Email
const brochureUrl = `https://e-tib.com/brochure/etib-product-catalog-${locale}.pdf`;
try {
const { sendEmail } = await import('@/lib/mail/mailer');
const { render } = await import('@mintel/mail');
const React = await import('react');
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
const html = await render(
React.createElement(BrochureDeliveryEmail, {
_email: email,
brochureUrl,
locale: locale as 'en' | 'de',
}),
);
const emailResult = await sendEmail({
to: email,
subject: locale === 'de' ? 'Ihr E-TIB Kabelkatalog' : 'Your E-TIB Cable Catalog',
html,
});
if (emailResult.success) {
logger.info('Brochure email sent successfully', { email });
} else {
logger.error('Failed to send brochure email', { error: emailResult.error, email });
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
action: 'requestBrochureAction_email',
email,
});
return { success: false, error: 'Failed to send email. Please try again later.' };
}
} catch (error) {
logger.error('Exception while sending brochure email', { error });
return { success: false, error: 'Failed to send email. Please try again later.' };
}
// 4. Track success
services.analytics.track('brochure-request-success', {
locale,
delivery_method: 'email',
});
return { success: true };
}

7
app/test/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function TestPage() {
return (
<div>
<h1>TEST PAGE WORKS</h1>
</div>
);
}

View File

@@ -215,9 +215,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<AnimatePresence mode="wait" initial={false}>
<m.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
initial={{ opacity: 0, scale: 1.1 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Button } from '@/components/ui/Button';
export interface CallToActionProps {
title?: string;
description?: string;
ctaLabel?: string;
ctaHref?: string;
theme?: 'light' | 'dark';
}
export const CallToAction: React.FC<CallToActionProps> = ({
title = 'Bereit für Ihr Projekt?',
description = 'Wir suchen stets nach neuen Herausforderungen und starken Partnern. Kontaktieren Sie uns für eine unverbindliche Beratung zu Ihrem Vorhaben.',
ctaLabel = 'Jetzt Kontakt aufnehmen',
ctaHref = '/de/kontakt',
theme = 'light'
}) => {
const isDark = theme === 'dark';
return (
<div className={`py-24 text-center ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-50 border-t border-neutral-100'}`}>
<div className="container max-w-3xl mx-auto px-4">
<h2 className={`font-heading text-4xl font-extrabold mb-6 ${isDark ? 'text-white' : 'text-neutral-dark'}`}>
{title}
</h2>
<p className={`text-xl mb-10 leading-relaxed ${isDark ? 'text-white/70' : 'text-text-secondary'}`}>
{description}
</p>
<Button
href={ctaHref}
size="xl"
variant={isDark ? 'white' : 'primary'}
>
{ctaLabel}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,53 @@
'use client';
import { useState, useEffect } from 'react';
interface DeferredVideoProps {
videoUrl: string;
pathname: string;
}
export function DeferredVideo({ videoUrl, pathname }: DeferredVideoProps) {
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
useEffect(() => {
let interactionTriggered = false;
const handleInteraction = () => {
if (interactionTriggered) return;
interactionTriggered = true;
setVideoSrcLoaded(true);
// Cleanup listeners
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.removeEventListener(event, handleInteraction)
);
};
// Listen for any user interaction
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.addEventListener(event, handleInteraction, { passive: true, once: true })
);
return () => {
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
window.removeEventListener(event, handleInteraction)
);
};
}, []);
if (!videoUrl) return null;
return (
<video
key={`${pathname}-${videoUrl}`}
className={`absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90 transition-opacity duration-1000 ${videoSrcLoaded ? 'opacity-100' : 'opacity-0'}`}
src={videoSrcLoaded ? videoUrl : undefined}
autoPlay
muted
loop
playsInline
preload="none"
/>
);
}

View File

@@ -1,9 +1,6 @@
'use client';
import { Button } from '@/components/ui/Button';
import Image from 'next/image';
import { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { DeferredVideo } from './DeferredVideo';
import { headers } from 'next/headers';
interface HeroVideoProps {
data?: any;
@@ -30,7 +27,10 @@ export function HeroVideo(props: HeroVideoProps) {
const subtitle = props.subtitle || props.description || data?.subtitle || 'Wir verbinden Infrastruktur mit Präzision. Von Horizontalbohrungen bis zu komplexen Leitungsnetzen.';
const explicitNoVideo = props.videoUrl === 'none' || props.videoUrl === '';
const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4');
const pathname = usePathname();
// In server components, we don't have usePathname, so we can extract it from headers if needed,
// or just use a default fallback for the keys.
const pathname = '/';
let defaultPoster = "/assets/photos/DJI_0048.JPG";
if (videoUrl && videoUrl.endsWith('.mp4')) {
@@ -43,37 +43,10 @@ export function HeroVideo(props: HeroVideoProps) {
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
const ctaHref = props.ctaHref || props.linkHref || data?.ctaHref || '#unternehmen';
const currentLocale = pathname?.startsWith('/en') ? 'en' : 'de';
const currentLocale = 'de'; // The layout or page should technically pass the locale, but for now fallback
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
const 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)
);
};
}, []);
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || 'Projekt anfragen';
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/de/contact`;
return (
<div className="relative w-full h-[85svh] md:h-[100svh] flex items-center justify-center overflow-hidden bg-neutral-dark">
@@ -87,25 +60,15 @@ export function HeroVideo(props: HeroVideoProps) {
sizes={posterSrc.includes('-poster.webp') ? "100vw" : undefined}
alt={posterAlt}
className="absolute inset-0 w-full h-full object-cover z-[1] pointer-events-none"
decoding="sync"
decoding="async"
loading="eager"
fetchPriority="high"
/>
{/* Fast compositing layer to replace the heavy CSS filters on the image */}
<div className="absolute inset-0 bg-black/20 z-[1] pointer-events-none mix-blend-multiply" />
{/* Render video on top if available, but defer src to avoid blocking LCP */}
{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 transition-opacity duration-1000 ${videoSrcLoaded ? 'opacity-100' : 'opacity-0'}`}
src={videoSrcLoaded ? videoUrl : undefined}
autoPlay
muted
loop
playsInline
preload="none"
/>
)}
<DeferredVideo videoUrl={videoUrl || ''} pathname={pathname} />
{/* Cinematic Color Grading Overlay */}
<div className="absolute inset-0 bg-[#0a192f]/30 z-[2] mix-blend-multiply" />

View File

@@ -0,0 +1,145 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
Button,
} from '@react-email/components';
import * as React from 'react';
interface BrochureDeliveryEmailProps {
_email: string;
brochureUrl: string;
locale: 'en' | 'de';
}
export const BrochureDeliveryEmail = ({
_email,
brochureUrl,
locale = 'en',
}: BrochureDeliveryEmailProps) => {
const t =
locale === 'de'
? {
subject: 'Ihr E-TIB Kabelkatalog',
greeting: 'Vielen Dank für Ihr Interesse an E-TIB.',
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
button: 'Katalog herunterladen',
footer: 'Diese E-Mail wurde von e-tib.com gesendet.',
}
: {
subject: 'Your E-TIB Cable Catalog',
greeting: 'Thank you for your interest in E-TIB.',
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
button: 'Download Catalog',
footer: 'This email was sent from e-tib.com.',
};
return (
<Html>
<Head />
<Preview>{t.subject}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={headerSection}>
<Heading style={h1}>{t.subject}</Heading>
</Section>
<Section style={section}>
<Text style={text}>
<strong>{t.greeting}</strong>
</Text>
<Text style={text}>{t.body}</Text>
<Section style={buttonContainer}>
<Button style={button} href={brochureUrl}>
{t.button}
</Button>
</Section>
<Hr style={hr} />
</Section>
<Text style={footer}>{t.footer}</Text>
</Container>
</Body>
</Html>
);
};
export default BrochureDeliveryEmail;
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '0 0 48px',
marginBottom: '64px',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid #e6ebf1',
};
const headerSection = {
backgroundColor: '#111111',
padding: '32px 48px',
borderBottom: '4px solid #117c61',
};
const h1 = {
color: '#ffffff',
fontSize: '24px',
fontWeight: 'bold',
margin: '0',
};
const section = {
padding: '32px 48px 0',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
textAlign: 'left' as const,
};
const buttonContainer = {
textAlign: 'center' as const,
marginTop: '32px',
marginBottom: '32px',
};
const button = {
backgroundColor: '#117c61',
borderRadius: '4px',
color: '#ffffff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '16px 32px',
};
const hr = {
borderColor: '#e6ebf1',
margin: '20px 0',
};
const footer = {
color: '#8898aa',
fontSize: '12px',
lineHeight: '16px',
textAlign: 'center' as const,
marginTop: '20px',
};

14
compress_images.py Normal file
View File

@@ -0,0 +1,14 @@
import os
from PIL import Image
def optimize_webp_aggressive(path, width):
if not os.path.exists(path):
return
img = Image.open(path)
wpercent = (width / float(img.size[0]))
hsize = int((float(img.size[1]) * float(wpercent)))
img = img.resize((width, hsize), Image.Resampling.LANCZOS)
img.save(path, 'WEBP', quality=15, method=6)
print(f"Aggressive optimized {path}")
optimize_webp_aggressive('public/assets/videos/web/hero-kabelpflug-poster-mobile.webp', 600)

View File

@@ -0,0 +1,30 @@
---
title: "Moderne Verfahren im Kabeltiefbau"
date: "2024-03-15T10:00:00.000Z"
excerpt: "Erfahren Sie mehr über die neuesten Technologien und Verfahren im modernen Kabelleitungstiefbau, von Horizontalspülbohrungen bis hin zu grabenlosen Verlegetechniken."
featuredImage: "/assets/images/blog/tiefbau.jpg"
category: "Technik"
public: true
---
# Moderne Verfahren im Kabeltiefbau
Der Kabelleitungstiefbau hat sich in den letzten Jahren rasant entwickelt. Neue Technologien ermöglichen eine effizientere und umweltschonendere Verlegung von Versorgungsleitungen.
## Grabenlose Verlegetechniken
Eines der wichtigsten Verfahren ist die **Horizontalspülbohrung (HDD)**. Dieses Verfahren ermöglicht es, Leitungen unter Hindernissen wie Straßen, Flüssen oder Gebäuden zu verlegen, ohne die Oberfläche aufbrechen zu müssen.
### Vorteile der HDD-Bohrung:
- Minimale Beeinträchtigung des Verkehrs
- Schutz der Umwelt und vorhandener Vegetation
- Kürzere Bauzeiten
- Geringere Kosten bei komplexen Unterquerungen
## Klassischer Tiefbau in neuer Qualität
Auch der klassische Grabenbau profitiert von modernen Maschinen und präziser Planung. Durch den Einsatz von Saugbaggern und GPS-gestützter Vermessung können wir Schäden an Bestandsleitungen nahezu ausschließen.
## Fazit
Die Kombination aus bewährter Handwerkskunst und modernster Technik ist der Schlüssel zu erfolgreichen Infrastrukturprojekten. Wir bei E-TIB setzen konsequent auf Innovation, um unseren Kunden die bestmöglichen Ergebnisse zu liefern.

View File

@@ -0,0 +1,30 @@
---
title: "Modern Methods in Cable Civil Engineering"
date: "2024-03-15T10:00:00.000Z"
excerpt: "Learn more about the latest technologies and methods in modern cable civil engineering, from horizontal directional drilling to trenchless installation techniques."
featuredImage: "/assets/images/blog/tiefbau.jpg"
category: "Technology"
public: true
---
# Modern Methods in Cable Civil Engineering
Cable civil engineering has evolved rapidly in recent years. New technologies enable more efficient and environmentally friendly installation of utility lines.
## Trenchless Installation Techniques
One of the most important methods is **Horizontal Directional Drilling (HDD)**. This method allows pipes to be laid under obstacles such as roads, rivers, or buildings without having to break open the surface.
### Benefits of HDD Drilling:
- Minimal impact on traffic
- Protection of the environment and existing vegetation
- Shorter construction times
- Lower costs for complex crossings
## Classic Civil Engineering in New Quality
Classic trench construction also benefits from modern machinery and precise planning. By using suction excavators and GPS-supported surveying, we can virtually eliminate damage to existing utility lines.
## Conclusion
The combination of proven craftsmanship and state-of-the-art technology is the key to successful infrastructure projects. At E-TIB, we consistently focus on innovation to deliver the best possible results for our customers.

6904
lh-report-prod-2.json Normal file

File diff suppressed because one or more lines are too long

6977
lh-report-prod-en-v2.json Normal file

File diff suppressed because one or more lines are too long

6973
lh-report-prod-en-v3.json Normal file

File diff suppressed because one or more lines are too long

6985
lh-report-prod-en-v4.json Normal file

File diff suppressed because one or more lines are too long

6993
lh-report-prod-en-v5.json Normal file

File diff suppressed because one or more lines are too long

6924
lh-report-prod-en-v7.json Normal file

File diff suppressed because one or more lines are too long

7002
lh-report-prod-en.json Normal file

File diff suppressed because one or more lines are too long

12388
lh-report-prod.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

388
lib/excel-products.ts Normal file
View File

@@ -0,0 +1,388 @@
/**
* 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();
}

1436
lib/pdf-brochure.tsx Normal file

File diff suppressed because it is too large Load Diff

329
lib/pdf-page.tsx Normal file
View File

@@ -0,0 +1,329 @@
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>
);
};

View File

@@ -66,6 +66,10 @@
"role": "Geschäftsführer",
"description": "Danny Joseph leitet die E-TIB GmbH und koordiniert die strategische Ausrichtung der gesamten Unternehmensgruppe vom Hauptsitz in Guben aus."
},
"gallery": {
"title": "Einblicke in unsere Arbeit",
"subtitle": "Eindrücke aus dem Arbeitsalltag und Projekten"
},
"groups": {
"title": "Unsere Unternehmensgruppe",
"subtitle": "Spezialisierte Einheiten für komplexe Projekte",

View File

@@ -63,8 +63,12 @@
},
"danny": {
"name": "Danny Joseph",
"role": "CEO",
"description": "Danny Joseph leads E-TIB GmbH and coordinates the strategic direction of the entire group from its headquarters in Guben."
"role": "Managing Director",
"description": "Danny Joseph leads E-TIB GmbH and coordinates the strategic direction of the entire corporate group from the headquarters in Guben."
},
"gallery": {
"title": "Insights into our work",
"subtitle": "Impressions from daily work and projects"
},
"groups": {
"title": "Our Corporate Group",

View File

@@ -1,6 +1,6 @@
{
"name": "e-tib-nextjs",
"version": "2.4.9",
"version": "2.4.16",
"type": "module",
"private": true,
"packageManager": "pnpm@10.18.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Some files were not shown because too many files have changed in this diff Show More