Compare commits
32 Commits
v1.0.9-rc.
...
v1.1.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 93cb12d7d9 | |||
| 44f0c430a9 | |||
| 1478909a73 | |||
| 837abd4921 | |||
| 75c6d363c0 | |||
| a2b7f28b9f | |||
| 52ecd1b052 | |||
| f0672600e4 | |||
| 61daeaf03f | |||
| 9d935ce03b | |||
| 9fab9a4536 | |||
| 291f6aa34f | |||
| a111851176 | |||
| 64c6873735 | |||
| 0d39beef70 | |||
| 95d0d094e1 | |||
| 38cf6a8d75 | |||
| ea55580e18 | |||
| df2dd23206 | |||
| 374fcc9689 | |||
| 02bd1dcd7f | |||
| 4b0433394f | |||
| d9bddae20e | |||
| e7c482dabf | |||
| 8974d89b33 | |||
| f99ca4d35d | |||
| d10f15abe3 | |||
| 9bdbcc2803 | |||
| b08f07494c | |||
| 1f758758e3 | |||
| fb8d9574b6 | |||
| 6856b7835c |
2
.env
2
.env
@@ -17,7 +17,7 @@ MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=https://cms.klz-cables.com
|
||||
DIRECTUS_URL=http://klz-cms:8055
|
||||
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
||||
|
||||
@@ -57,6 +57,9 @@ SENTRY_DSN=
|
||||
IMAGE_TAG=latest
|
||||
TRAEFIK_HOST=klz-cables.com
|
||||
ENV_FILE=.env
|
||||
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
|
||||
# Next.js will proxy requests from /_img to this URL.
|
||||
IMGPROXY_URL=https://img.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Varnish Configuration
|
||||
|
||||
@@ -33,4 +33,10 @@ jobs:
|
||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🧪 QA Checks
|
||||
run: pnpm lint && pnpm typecheck && pnpm test
|
||||
run: pnpm check:mdx && pnpm lint && pnpm typecheck && pnpm test
|
||||
|
||||
- name: 🏗️ Build
|
||||
run: pnpm build
|
||||
|
||||
- name: ♿ Accessibility Check
|
||||
run: pnpm check:a11y
|
||||
|
||||
@@ -100,7 +100,11 @@ jobs:
|
||||
echo "traefik_rule=$TRAEFIK_RULE"
|
||||
echo "next_public_url=https://$PRIMARY_HOST"
|
||||
echo "directus_url=https://cms.$PRIMARY_HOST"
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
echo "project_name=klz-cablescom"
|
||||
else
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
fi
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -402,11 +406,79 @@ jobs:
|
||||
run: pnpm run check:og
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# JOB 6: Lighthouse (Performance & Accessibility)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
lighthouse:
|
||||
name: ⚡ Lighthouse
|
||||
needs: [prepare, deploy]
|
||||
if: success() && needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
|
||||
# Detect OS
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
echo "🎯 Debian detected - installing native chromium"
|
||||
apt-get install -y chromium
|
||||
else
|
||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
|
||||
# Fetch PPA key
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
|
||||
# Add PPA repository
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
|
||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
|
||||
# Standardize binary paths
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
- name: ⚡ Run Lighthouse CI
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
PAGESPEED_LIMIT: 8
|
||||
run: pnpm run pagespeed:test
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 7: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, smoke_test]
|
||||
needs: [prepare, deploy, smoke_test, lighthouse]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,11 @@ node_modules
|
||||
.next
|
||||
.DS_Store
|
||||
|
||||
# Lighthouse CI
|
||||
.lighthouseci/
|
||||
lighthouserc.cjs
|
||||
.lighthouserc.json
|
||||
|
||||
# Directus
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
|
||||
32
.husky/pre-push
Executable file
32
.husky/pre-push
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Husky pre-push hook to validate tags
|
||||
# Strictly enforces that all pushed tags start with 'v' (e.g., v1.0.0)
|
||||
|
||||
z40=0000000000000000000000000000000000000000
|
||||
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
# Check if we are pushing a tag
|
||||
case "$local_ref" in
|
||||
refs/tags/*)
|
||||
tag_name="${local_ref#refs/tags/}"
|
||||
if ! echo "$tag_name" | grep -q "^v[0-9]"; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Invalid tag name '$tag_name'"
|
||||
echo "--------------------------------------------------"
|
||||
echo "Consistency check failed: All tags MUST start with 'v'."
|
||||
echo "Example: v1.0.10"
|
||||
echo ""
|
||||
echo "Please delete the invalid tag and create a new one:"
|
||||
echo " git tag -d $tag_name"
|
||||
echo " git tag v$tag_name"
|
||||
echo "--------------------------------------------------"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit 0
|
||||
26
.pa11yci.json
Normal file
26
.pa11yci.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"defaults": {
|
||||
"standard": "WCAG2AA",
|
||||
"runners": ["axe", "htmlcs"],
|
||||
"ignore": [],
|
||||
"timeout": 50000,
|
||||
"wait": 1000,
|
||||
"chromeLaunchConfig": {
|
||||
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||
},
|
||||
"threshold": 25
|
||||
},
|
||||
"urls": [
|
||||
"http://localhost:3000/en",
|
||||
"http://localhost:3000/en/blog",
|
||||
"http://localhost:3000/en/blog/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2",
|
||||
"http://localhost:3000/en/contact",
|
||||
"http://localhost:3000/en/team",
|
||||
"http://localhost:3000/en/products",
|
||||
"http://localhost:3000/en/products/medium-voltage-cables",
|
||||
"http://localhost:3000/en/products/low-voltage-cables",
|
||||
"http://localhost:3000/en/products/medium-voltage-cables/n2xs2y",
|
||||
"http://localhost:3000/en/legal-notice",
|
||||
"http://localhost:3000/en/privacy-policy"
|
||||
]
|
||||
}
|
||||
@@ -32,11 +32,6 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
description: description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/blog/${slug}`,
|
||||
en: `${SITE_URL}/en/blog/${slug}`,
|
||||
'x-default': `${SITE_URL}/en/blog/${slug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||
|
||||
@@ -58,7 +58,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
<div className="bg-neutral-light min-h-screen">
|
||||
{/* Hero Section - Immersive Magazine Feel */}
|
||||
<Reveal>
|
||||
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||
<>
|
||||
<Image
|
||||
@@ -101,7 +101,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</article>
|
||||
</Reveal>
|
||||
|
||||
<Section className="bg-neutral-light py-12 md:py-28">
|
||||
@@ -146,7 +146,10 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{remainingPosts.map((post, idx) => (
|
||||
<Reveal key={post.slug} delay={idx * 100}>
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
||||
<Card
|
||||
tag="article"
|
||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
|
||||
>
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||
<Image
|
||||
|
||||
@@ -136,7 +136,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||
{t('info.howToReachUs')}
|
||||
</Heading>
|
||||
<div className="space-y-4 md:space-y-8">
|
||||
<address className="space-y-4 md:space-y-8 not-italic">
|
||||
<div className="flex items-start gap-4 md:gap-6 group">
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg
|
||||
@@ -197,7 +197,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import Footer from '@/components/Footer';
|
||||
import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
|
||||
import SkipLink from '@/components/SkipLink';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
|
||||
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
|
||||
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
@@ -23,22 +24,37 @@ const inter = Inter({
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||
},
|
||||
};
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { locale } = params;
|
||||
|
||||
return {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
canonical: locale === 'en' ? '/' : `/${locale}`,
|
||||
languages: {
|
||||
de: '/de',
|
||||
en: '/en',
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
@@ -80,7 +96,8 @@ export default async function Layout(props: {
|
||||
});
|
||||
}
|
||||
|
||||
serverServices.analytics.trackPageview();
|
||||
// Server-side analytics tracking removed to prevent duplicate/empty events.
|
||||
// Client-side AnalyticsProvider handles all pageviews.
|
||||
} catch {
|
||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||
console.warn(
|
||||
@@ -100,18 +117,22 @@ export default async function Layout(props: {
|
||||
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
||||
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||
<RecordModeVisuals>
|
||||
<SkipLink />
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow animate-fade-in overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</RecordModeVisuals>
|
||||
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsProvider />
|
||||
<ScrollDepthTracker />
|
||||
</Suspense>
|
||||
<AnalyticsShell />
|
||||
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
||||
</RecordModeProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -79,7 +79,9 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
const title = t('title') || 'KLZ Cables';
|
||||
const description = t('description') || '';
|
||||
const description =
|
||||
t('description') ||
|
||||
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
|
||||
|
||||
return {
|
||||
title,
|
||||
|
||||
@@ -5,7 +5,7 @@ import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
@@ -239,57 +239,59 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
|
||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||
>
|
||||
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||
{product.frontmatter.images?.[0] && (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.frontmatter.categories.map((cat, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||
>
|
||||
{cat}
|
||||
<Card tag="article" className="premium-card-reset">
|
||||
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||
{product.frontmatter.images?.[0] && (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.frontmatter.categories.map((cat, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||
{product.frontmatter.title}
|
||||
</h2>
|
||||
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
{t('details')}
|
||||
</span>
|
||||
))}
|
||||
<svg
|
||||
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||
{product.frontmatter.title}
|
||||
</h2>
|
||||
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
{t('details')}
|
||||
</span>
|
||||
<svg
|
||||
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
</Reveal>
|
||||
|
||||
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
||||
<section className="relative bg-white overflow-hidden">
|
||||
<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" />
|
||||
@@ -161,7 +161,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<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>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* Legacy Section - Immersive Background */}
|
||||
<Reveal>
|
||||
@@ -217,7 +217,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
</Reveal>
|
||||
|
||||
{/* Klaus Mintel Section - Reversed Split Layout */}
|
||||
<section className="relative bg-white overflow-hidden">
|
||||
<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
|
||||
@@ -264,7 +264,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* Manifesto Section - Modern Grid */}
|
||||
<Section className="bg-white text-primary py-16 md:py-28">
|
||||
@@ -292,9 +292,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
||||
<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) => (
|
||||
<div
|
||||
<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"
|
||||
>
|
||||
@@ -309,9 +309,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||
{t(`manifesto.items.${idx}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
@@ -65,9 +65,28 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: (error as Error).message,
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
// Console error to ensure it appears in logs even if logger fails
|
||||
console.error('CRITICAL PROXY ERROR:', {
|
||||
message: errorMessage,
|
||||
stack: errorStack,
|
||||
endpoint: config.analytics.umami.apiEndpoint,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
details: errorMessage, // Expose error for debugging
|
||||
endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,11 @@ export default function ContactForm() {
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
||||
<Card
|
||||
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||
<svg
|
||||
className="w-10 h-10 text-primary-dark"
|
||||
@@ -93,7 +97,11 @@ export default function ContactForm() {
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
||||
<Card
|
||||
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||
<svg
|
||||
className="w-10 h-10 text-destructive-foreground"
|
||||
@@ -132,40 +140,43 @@ export default function ContactForm() {
|
||||
</Heading>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="name">{t('form.name')}</Label>
|
||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
id="contact-name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
onFocus={() => handleFocus('name')}
|
||||
onFocus={() => handleFocus('contact-name')}
|
||||
aria-label={t('form.name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="email">{t('form.email')}</Label>
|
||||
<Label htmlFor="contact-email">{t('form.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
id="contact-email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
onFocus={() => handleFocus('email')}
|
||||
onFocus={() => handleFocus('contact-email')}
|
||||
aria-label={t('form.email')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<Label htmlFor="message">{t('form.message')}</Label>
|
||||
<Label htmlFor="contact-message">{t('form.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
id="contact-message"
|
||||
name="message"
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
onFocus={() => handleFocus('message')}
|
||||
onFocus={() => handleFocus('contact-message')}
|
||||
aria-label={t('form.message')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -69,7 +70,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Footer() {
|
||||
>
|
||||
<Image
|
||||
src="/logo-white.svg"
|
||||
alt={t('products')}
|
||||
alt="KLZ Vertriebs GmbH"
|
||||
width={150}
|
||||
height={40}
|
||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||
@@ -67,9 +67,9 @@ export default function Footer() {
|
||||
|
||||
{/* Links Columns */}
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('legal')}
|
||||
</h4>
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
<li>
|
||||
<Link
|
||||
@@ -120,9 +120,9 @@ export default function Footer() {
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('company')}
|
||||
</h4>
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
<li>
|
||||
<Link
|
||||
@@ -189,9 +189,9 @@ export default function Footer() {
|
||||
|
||||
{/* Recent Posts Column */}
|
||||
<div className="lg:col-span-4">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
||||
{t('recentPosts')}
|
||||
</h4>
|
||||
</h3>
|
||||
<ul className="space-y-6 list-none m-0 p-0">
|
||||
{[
|
||||
{
|
||||
@@ -230,7 +230,7 @@ export default function Footer() {
|
||||
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
||||
{post.title}
|
||||
</p>
|
||||
<span className="text-xs text-white/40 uppercase tracking-widest">
|
||||
<span className="text-xs text-white/70 uppercase tracking-widest">
|
||||
{t('readArticle')} →
|
||||
</span>
|
||||
</Link>
|
||||
@@ -240,7 +240,7 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
|
||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
|
||||
<p>{t('copyright', { year: currentYear })}</p>
|
||||
<div className="flex gap-8">
|
||||
<Link
|
||||
|
||||
@@ -6,7 +6,7 @@ import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Button } from './ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { cn } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
@@ -17,6 +17,8 @@ export default function Header() {
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Extract locale from pathname
|
||||
const currentLocale = pathname.split('/')[1] || 'en';
|
||||
@@ -34,9 +36,52 @@ export default function Header() {
|
||||
}, []);
|
||||
|
||||
// Prevent scroll when mobile menu is open
|
||||
// Prevent scroll when mobile menu is open and handle focus trap
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus trap logic
|
||||
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
|
||||
if (focusableElements && focusableElements.length > 0) {
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
const handleTabKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleTabKey);
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
// Focus the first element when menu opens
|
||||
setTimeout(() => firstElement.focus(), 100);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleTabKey);
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
@@ -227,6 +272,8 @@ export default function Header() {
|
||||
textColorClass,
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{
|
||||
@@ -288,8 +335,13 @@ export default function Header() {
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
>
|
||||
<motion.div
|
||||
<motion.nav
|
||||
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||
initial="closed"
|
||||
animate={isMobileMenuOpen ? 'open' : 'closed'}
|
||||
@@ -411,7 +463,7 @@ export default function Header() {
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.nav>
|
||||
</div>
|
||||
</motion.header>
|
||||
</>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// Fix for default marker icon in Leaflet with Next.js
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
}
|
||||
|
||||
interface LeafletMapProps {
|
||||
address: string;
|
||||
@@ -21,25 +21,46 @@ interface LeafletMapProps {
|
||||
}
|
||||
|
||||
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
||||
const position: [number, number] = [lat, lng];
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={position}
|
||||
zoom={15}
|
||||
scrollWheelZoom={false}
|
||||
className="h-full w-full z-0"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={position}>
|
||||
<Popup>
|
||||
<div className="text-primary font-bold">KLZ Cables</div>
|
||||
<div className="text-sm whitespace-pre-line">{address}</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
// Initialize map
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [lat, lng],
|
||||
zoom: 15,
|
||||
scrollWheelZoom: false,
|
||||
});
|
||||
|
||||
// Add tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([lat, lng]).addTo(map);
|
||||
|
||||
// Create popup content
|
||||
const popupContent = `
|
||||
<div class="text-primary font-bold">KLZ Cables</div>
|
||||
<div class="text-sm">${address.replace(/\n/g, '<br/>')}</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [lat, lng, address]);
|
||||
|
||||
return <div ref={mapRef} className="h-full w-full z-0" />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@@ -19,6 +19,8 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
const pathname = usePathname();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
@@ -76,12 +78,50 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
}, [isOpen, currentIndex, updateUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen) {
|
||||
if (previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture previous focus
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus close button on open
|
||||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
if (e.key === 'ArrowLeft') prevImage();
|
||||
if (e.key === 'ArrowRight') nextImage();
|
||||
|
||||
// Focus Trap
|
||||
if (e.key === 'Tab') {
|
||||
const focusableElements = document.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const modalElements = Array.from(focusableElements).filter((el) =>
|
||||
document.querySelector('[role="dialog"]')?.contains(el),
|
||||
);
|
||||
|
||||
if (modalElements.length === 0) return;
|
||||
|
||||
const firstElement = modalElements[0] as HTMLElement;
|
||||
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lock scroll
|
||||
@@ -101,7 +141,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -116,6 +160,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
ref={closeButtonRef}
|
||||
onClick={handleClose}
|
||||
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
||||
aria-label="Close lightbox"
|
||||
|
||||
@@ -14,25 +14,30 @@ interface ProductSidebarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProductSidebar({ productName, productImage, datasheetPath, className }: ProductSidebarProps) {
|
||||
export default function ProductSidebar({
|
||||
productName,
|
||||
productImage,
|
||||
datasheetPath,
|
||||
className,
|
||||
}: ProductSidebarProps) {
|
||||
const t = useTranslations('Products');
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4 animate-slight-fade-in-from-bottom", className)}>
|
||||
<aside className={cn('flex flex-col gap-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
{/* Request Quote Form Card */}
|
||||
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
||||
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
||||
{/* Background Accent - Saturated Blue Glow */}
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-saturated/30 rounded-full -translate-y-1/2 translate-x-1/2 blur-[80px] pointer-events-none" />
|
||||
|
||||
|
||||
{/* Product Thumbnail with Reflection */}
|
||||
{productImage && (
|
||||
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
||||
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
||||
<Image
|
||||
src={productImage}
|
||||
alt={productName}
|
||||
fill
|
||||
<Image
|
||||
src={productImage}
|
||||
alt={productName}
|
||||
fill
|
||||
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
||||
/>
|
||||
{/* Subtle Reflection Overlay */}
|
||||
@@ -46,9 +51,9 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
||||
<h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
|
||||
{t('requestQuote')}
|
||||
</h3>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
||||
color="var(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
@@ -57,16 +62,14 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 bg-neutral-light/50">
|
||||
<RequestQuoteForm productName={productName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && (
|
||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
||||
)}
|
||||
</div>
|
||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
const { technicalItems = [], voltageTables = [] } = data;
|
||||
|
||||
const toggleTable = (idx: number) => {
|
||||
setExpandedTables(prev => ({
|
||||
setExpandedTables((prev) => ({
|
||||
...prev,
|
||||
[idx]: !prev[idx]
|
||||
[idx]: !prev[idx],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -48,9 +48,16 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
||||
{technicalItems.map((item, idx) => (
|
||||
<div key={idx} className="flex flex-col group">
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">{item.label}</dt>
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{item.value} {item.unit && <span className="text-sm font-normal text-text-secondary ml-1">{item.unit}</span>}
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
@@ -61,29 +68,38 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
{voltageTables.map((table, idx) => {
|
||||
const isExpanded = expandedTables[idx];
|
||||
const hasManyRows = table.rows.length > 10;
|
||||
|
||||
|
||||
return (
|
||||
<div key={idx} className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden">
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
{table.voltageLabel !== 'Voltage unknown' &&
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
: 'Technical Specifications'}
|
||||
</h3>
|
||||
|
||||
|
||||
{table.metaItems.length > 0 && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
||||
{table.metaItems.map((item, mIdx) => (
|
||||
<div key={mIdx}>
|
||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">{item.label}</dt>
|
||||
<dd className="font-bold text-primary">{item.value} {item.unit}</dd>
|
||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="font-bold text-primary">
|
||||
{item.value} {item.unit}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
<div
|
||||
id={`voltage-table-${idx}`}
|
||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
@@ -91,11 +107,18 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<table className="min-w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10"
|
||||
>
|
||||
Config.
|
||||
</th>
|
||||
{table.columns.map((col, cIdx) => (
|
||||
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10">
|
||||
<th
|
||||
key={cIdx}
|
||||
scope="col"
|
||||
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
@@ -108,7 +131,10 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
{row.configuration}
|
||||
</td>
|
||||
{row.cells.map((cell, cellIdx) => (
|
||||
<td key={cellIdx} className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap">
|
||||
<td
|
||||
key={cellIdx}
|
||||
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
@@ -127,6 +153,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<div className="mt-8 flex justify-center">
|
||||
<button
|
||||
onClick={() => toggleTable(idx)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`voltage-table-${idx}`}
|
||||
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
|
||||
>
|
||||
{isExpanded ? t('showLess') : t('showMore')}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default async function RelatedProducts({
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
|
||||
@@ -80,7 +80,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div
|
||||
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||
<svg
|
||||
@@ -118,7 +122,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div
|
||||
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||
<svg
|
||||
@@ -158,25 +166,27 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
<div className="space-y-1 !mt-0">
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
id="quote-email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => handleFocus('email')}
|
||||
onFocus={() => handleFocus('quote-email')}
|
||||
placeholder={t('email')}
|
||||
aria-label={t('email')}
|
||||
className="h-9 text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 !mt-0">
|
||||
<Textarea
|
||||
id="request"
|
||||
id="quote-request"
|
||||
required
|
||||
rows={3}
|
||||
value={request}
|
||||
onChange={(e) => setRequest(e.target.value)}
|
||||
onFocus={() => handleFocus('request')}
|
||||
onFocus={() => handleFocus('quote-request')}
|
||||
placeholder={t('message')}
|
||||
aria-label={t('message')}
|
||||
className="text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,31 +18,31 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 1.8,
|
||||
ease: "easeInOut",
|
||||
}
|
||||
}
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (variant === 'circle') {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute pointer-events-none", className)}
|
||||
role="presentation"
|
||||
viewBox="0 0 800 350"
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 800 350"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<motion.path
|
||||
<motion.path
|
||||
variants={pathVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||
/>
|
||||
</svg>
|
||||
@@ -51,20 +51,20 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
||||
|
||||
if (variant === 'underline') {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute pointer-events-none", className)}
|
||||
role="presentation"
|
||||
viewBox="-400 -55 730 60"
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="-400 -55 730 60"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<motion.path
|
||||
<motion.path
|
||||
variants={pathVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||
stroke={color}
|
||||
strokeWidth="20"
|
||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||
stroke={color}
|
||||
strokeWidth="20"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
16
components/SkipLink.tsx
Normal file
16
components/SkipLink.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function SkipLink() {
|
||||
const t = useTranslations('Navigation');
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-6 focus:py-3 focus:bg-white focus:text-primary-dark focus:font-bold focus:rounded-lg focus:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent transition-all"
|
||||
>
|
||||
{t('skipToContent')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
20
components/analytics/AnalyticsShell.tsx
Normal file
20
components/analytics/AnalyticsShell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||
ssr: false,
|
||||
});
|
||||
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function AnalyticsShell() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DynamicAnalyticsProvider />
|
||||
<DynamicScrollDepthTracker />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -14,57 +14,84 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
|
||||
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Decorative accent */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 rounded-full blur-3xl -mr-32 -mt-32 transition-transform group-hover:scale-110 duration-1000" />
|
||||
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="inline-block px-4 py-1 bg-accent/20 text-accent text-xs font-bold uppercase tracking-[0.2em] rounded-full mb-8">
|
||||
{isDe ? 'Lösungen' : 'Solutions'}
|
||||
</div>
|
||||
|
||||
|
||||
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight">
|
||||
{isDe ? 'Bereit für die' : 'Ready for the'}
|
||||
{isDe ? 'Bereit für die' : 'Ready for the'}
|
||||
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
||||
</h3>
|
||||
|
||||
|
||||
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
|
||||
{isDe
|
||||
{isDe
|
||||
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.'
|
||||
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'
|
||||
}
|
||||
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'}
|
||||
</p>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
||||
{[
|
||||
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
|
||||
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
||||
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects',
|
||||
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards'
|
||||
isDe
|
||||
? 'Expertenberatung für Großprojekte'
|
||||
: 'Expert consulting for large-scale projects',
|
||||
isDe
|
||||
? 'Zertifizierte Qualität nach EU-Standards'
|
||||
: 'Certified quality according to EU standards',
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-4 text-white/80">
|
||||
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
className="w-3 h-3 text-accent"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
className="inline-flex items-center gap-3 px-8 py-4 bg-primary text-white font-bold rounded-full hover:bg-primary/90 transition-all shadow-xl hover:shadow-primary/20 transform hover:-translate-y-1 group/btn"
|
||||
>
|
||||
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
|
||||
<svg className="w-5 h-5 transition-transform group-hover/btn:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-5 h-5 transition-transform group-hover/btn:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<p className="text-white/50 text-sm font-medium">
|
||||
{isDe ? 'Kostenlose Erstberatung für Ihr Vorhaben.' : 'Free initial consultation for your project.'}
|
||||
{isDe
|
||||
? 'Kostenlose Erstberatung für Ihr Vorhaben.'
|
||||
: 'Free initial consultation for your project.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,24 +32,24 @@ export default function Experience() {
|
||||
<p className="pl-9">{t('p2')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="animate-fade-in">
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
{t('certifiedQuality')}
|
||||
</div>
|
||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
</dt>
|
||||
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
{t('vdeApproved')}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
||||
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
|
||||
{t('fullSpectrum')}
|
||||
</div>
|
||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
</dt>
|
||||
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||
{t('solutionsRange')}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function GallerySection() {
|
||||
{images.map((src, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
aria-label={`${t('alt')} ${idx + 1}`}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('photo', idx.toString());
|
||||
@@ -47,6 +49,7 @@ export default function GallerySection() {
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
|
||||
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />
|
||||
|
||||
@@ -130,19 +130,19 @@ const containerVariants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.12,
|
||||
delayChildren: 0.4,
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const headingVariants = {
|
||||
hidden: { opacity: 0, y: 60, scale: 0.85 },
|
||||
hidden: { opacity: 1, y: 30, scale: 0.95 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export default function HeroIllustration() {
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function ProductCategories() {
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||
<h2 className="sr-only">{t('title')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||
{categories.map((category, idx) => (
|
||||
<Link
|
||||
|
||||
@@ -32,60 +32,69 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
|
||||
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
|
||||
{recentPosts.map((post) => (
|
||||
<Link key={post.slug} href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||
{t('readMore')}
|
||||
<svg
|
||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
<li key={post.slug}>
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
|
||||
<Card
|
||||
tag="article"
|
||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
|
||||
>
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
||||
{t('readMore')}
|
||||
<svg
|
||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -17,32 +17,54 @@ export default function WhyChooseUs() {
|
||||
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="mt-12 space-y-6">
|
||||
|
||||
<ul className="mt-12 space-y-6 list-none p-0">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<li key={i} className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
className="w-4 h-4 text-primary-dark"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
|
||||
</div>
|
||||
<span className="font-bold text-primary-dark text-base md:text-base">
|
||||
{t(`features.${i}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
|
||||
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0">
|
||||
{[0, 1, 2, 3].map((idx) => (
|
||||
<div key={idx} className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group">
|
||||
<li
|
||||
key={idx}
|
||||
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
|
||||
>
|
||||
<div className="w-14 h-14 bg-saturated/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
|
||||
<span className="text-white font-bold text-lg group-hover:text-primary-dark">0{idx + 1}</span>
|
||||
<span className="text-white font-bold text-lg group-hover:text-primary-dark">
|
||||
0{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
|
||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-primary-dark">
|
||||
{t(`items.${idx}.title`)}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">
|
||||
{t(`items.${idx}.description`)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import { cn } from './utils';
|
||||
|
||||
export function Card({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
interface CardProps extends React.HTMLAttributes<HTMLElement> {
|
||||
tag?: 'div' | 'article' | 'section' | 'aside' | 'header' | 'footer' | 'nav' | 'main';
|
||||
}
|
||||
|
||||
export function Card({ className, children, tag: Tag = 'div', ...props }: CardProps) {
|
||||
return (
|
||||
<div className={cn('premium-card overflow-hidden', className)} {...props}>
|
||||
<Tag className={cn('premium-card overflow-hidden', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
52
config/lighthouserc.json
Normal file
52
config/lighthouserc.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"preset": "desktop",
|
||||
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.8
|
||||
}
|
||||
],
|
||||
"categories:accessibility": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.9
|
||||
}
|
||||
],
|
||||
"categories:best-practices": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.9
|
||||
}
|
||||
],
|
||||
"categories:seo": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.9
|
||||
}
|
||||
],
|
||||
"first-contentful-paint": [
|
||||
"warn",
|
||||
{
|
||||
"maxNumericValue": 2000
|
||||
}
|
||||
],
|
||||
"interactive": [
|
||||
"warn",
|
||||
{
|
||||
"maxNumericValue": 3500
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ date: '2025-03-31T12:00:34'
|
||||
featuredImage: /uploads/2025/02/image_fx_-6.webp
|
||||
locale: de
|
||||
category: Kabel Technologie
|
||||
excerpt: Die Energiewende braucht leistungsfähige Netze. Erfahren Sie, warum Investitionen in die Kabelinfrastruktur der Schlüssel zu 100 % erneuerbarer Energie sind.
|
||||
---
|
||||
# 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
|
||||
Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält.
|
||||
|
||||
@@ -24,15 +24,9 @@ image="https://www.zdf.de/assets/bundestag-berlin-118~1280x720?cb=1741856505967"
|
||||
### Warum Kabelhersteller jetzt durchstarten sollten
|
||||
Es wird viel über Subventionen, Fördergelder und deren Verwendung gesprochen. Doch die eigentliche Herausforderung bleibt: Die notwendige Infrastruktur muss geschaffen werden – und das gelingt nur mit leistungsfähigen Kabeln.
|
||||
Die folgenden Trends sind für uns besonders relevant:
|
||||
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br />
|
||||
|
||||
</strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
|
||||
- <strong>Dezentralisierung der Energieversorgung:<br />
|
||||
|
||||
</strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt – und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
|
||||
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br />
|
||||
|
||||
</strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
|
||||
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br /></strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
|
||||
- <strong>Dezentralisierung der Energieversorgung:<br /></strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt – und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
|
||||
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br /></strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
|
||||
Dies betrifft insbesondere Kabelsysteme, die für hohe Leistung und Stabilität ausgelegt sind – so wie die, die wir bei **KLZ** liefern.
|
||||
### **Die Rolle von KLZ in dieser gigantischen Investitionsoffensive**
|
||||
Mit diesen milliardenschweren Investitionen wird der Bedarf an Erdkabeln, insbesondere Mittelspannungskabeln, geradezu explodieren. Die Frage ist nicht, **ob** Kabel gebraucht werden – sondern **wann und in welchen Mengen**. Und genau da kommen wir ins Spiel.
|
||||
|
||||
@@ -24,35 +24,21 @@ There is an often overlooked problem with solar power systems: energy loss due t
|
||||
But why does this happen? Every cable has a resistance that slows down the flow of electricity. The poorer the quality of the cable, the more energy is lost in the form of heat. This means that less of the electricity produced by the solar system actually reaches you and can be used. And that is obviously a problem, especially when you consider how much is invested in the installation of a solar system.
|
||||
### Green energy is a central component of our future today … But it is not enough to simply rely on these energy sources. The infrastructure that brings this energy to us efficiently plays an equally crucial role.
|
||||
High-quality cables, on the other hand, have better conductivity and lower resistance. This ensures that the **electricity flows more efficiently and less is lost**. This leaves more of the generated energy for you to use – which is not only good for your electricity bill, but also helps to maximize the sustainability of your solar system. So it’s worth paying attention to quality when choosing cables in order to exploit the full potential of green energy.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://ratedpower.com/blog/utility-scale-pv-losses/"
|
||||
title="Ultimate guide to utility-scale PV system losses — RatedPower"
|
||||
summary="What are solar PV system losses and how can you avoid them to maximize the electrical output from your utility-scale plant project?"
|
||||
image="https://assets.ratedpower.com/1694509274-aerial-view-solar-panels-top-building-eco-building-factory-solar-photovoltaic-cell.jpg?auto=format&fit=crop&h=630&w=1200"
|
||||
/>
|
||||
"
|
||||
title="Ultimate guide to utility-scale PV system losses — RatedPower"
|
||||
summary="What are solar PV system losses and how can you avoid them to maximize the electrical output from your utility-scale plant project?"
|
||||
image="https://assets.ratedpower.com/1694509274-aerial-view-solar-panels-top-building-eco-building-factory-solar-photovoltaic-cell.jpg?auto=format&fit=crop&h=630&w=1200"
|
||||
/>
|
||||
<h4>Fact 2: Wind farms without energy storage are not that efficient</h4>
|
||||
Wind farms have a similar problem to solar plants: energy losses due to fluctuating power generation. Imagine a wind farm produces electricity, but the wind does not blow constantly. This means that at certain times the wind turbines generate more electricity than is actually needed, while at other times, when the wind drops, they can supply almost no electricity at all. In both cases, a lot of energy is lost or not used. Without a way to **store surplus energy**, there is a gap between the energy generated and the actual use, which significantly reduces the efficiency of the entire system.The solution to this problem lies in** energy storage systems** such as batteries or pumped storage power plants. These technologies make it possible to store surplus energy when the wind is blowing strongly and therefore more electricity is produced than is required at the moment. This stored energy can then be used on demand when the wind dies down or demand is particularly high. This ensures that all the electricity generated is used efficiently instead of being lost unused. Without these storage technologies, the full potential of wind energy remains untapped and the efficiency of wind farms remains far below their actual value.
|
||||
However, despite their importance, energy storage systems are associated with challenges. High costs and limited capacity continue to make the development and installation of these storage technologies a difficult endeavor. But technological progress has not stood still: New innovations in storage technologies and increasingly improved scalability are making it more and more realistic to equip wind farms with **effective and cost-efficient storage systems**. This is crucial for the future of wind energy, because only by overcoming these challenges can wind energy fully contribute to ensuring a stable and sustainable energy supply.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.solarenergie.de/stromspeicher/arten/stromspeicher-windkraft"
|
||||
title="Speicher für Windenergie: Welche Möglichkeiten gibt es?"
|
||||
summary="Speicher für Windenergie: Welche Möglichkeiten gibt es? Windkraftanlagen mit Speicher im privaten und im öffentlichen Bereich ✓ Wie kann man Windenergie speichern? – Lernen Sie hier bereits existente und sich derzeit in der Forschung befindende Verfahren der Zukunft kennen!"
|
||||
image="https://assets.solarwatt.de/Resources/Persistent/e084aa09af5f0cdef386088bc558a52d81509cc0/Regenerative%20Energie-1200x628.jpg"
|
||||
/>
|
||||
"
|
||||
title="Speicher für Windenergie: Welche Möglichkeiten gibt es?"
|
||||
summary="Speicher für Windenergie: Welche Möglichkeiten gibt es? Windkraftanlagen mit Speicher im privaten und im öffentlichen Bereich ✓ Wie kann man Windenergie speichern? – Lernen Sie hier bereits existente und sich derzeit in der Forschung befindende Verfahren der Zukunft kennen!"
|
||||
image="https://assets.solarwatt.de/Resources/Persistent/e084aa09af5f0cdef386088bc558a52d81509cc0/Regenerative%20Energie-1200x628.jpg"
|
||||
/>
|
||||
<h4>Fact 3: Power lines can be used as habitats for biodiversity</h4>
|
||||
Did you know that power lines – the high-voltage lines that transport electricity from power plants to our homes and businesses – can also be used as** habitats for animals and plants**? These areas, which often need to be kept clear to make room for the power lines, provide a valuable opportunity to actively promote biodiversity and contribute to environmental stewardship at the same time.Traditionally, the areas along power lines have often been regarded as “wasteland” with no particular significance. However, innovative approaches to green infrastructure are increasingly creating valuable habitats here. Today, wildflower meadows, bee pastures and shrubs are planted along power lines, providing habitats for many endangered species. These meadows are not only a source of food for bees, butterflies and other pollinators, but also a refuge for birds and small animals that are finding less and less habitat in other parts of the landscape.
|
||||
In Germany, this is a growing concept known as “**ecological route maintenance**”. Here, care is taken to ensure that the areas along the power lines are designed in a near-natural way so that biodiversity is promoted. This creates flowering meadows and habitats for numerous insect species, which are finding less and less space due to the intensive use of agriculture and urbanization. There are also similar projects in Switzerland in which nature is specifically promoted along power lines.
|
||||
|
||||
@@ -31,19 +31,12 @@ Integrating wind farms into the power grid requires a systemic approach. Sound p
|
||||
Professional planning not only ensures security of supply, but also reduces operating costs in the long term and enables flexible responses to grid requirements.
|
||||
You can find more information here on how wind energy basically works:
|
||||
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.e-werk-mittelbaden.de/wie-funktioniert-windenergie"
|
||||
title="Wie funktioniert Windenergie? - Einfach erklärt | E-Werk Mittelbaden"
|
||||
summary="Erfahren Sie, wie Windenergie funktioniert und wie sie zur nachhaltigen Energieversorgung beiträgt. Jetzt informieren!"
|
||||
image="https://www.e-werk-mittelbaden.de/sites/default/files/media_image/2024-12/DJI_20231105012629_0029_D-HDR.jpg"
|
||||
/>
|
||||
"
|
||||
title="Wie funktioniert Windenergie?"
|
||||
summary="- Einfach erklärt | E-Werk MittelbadenErfahren Sie, wie Windenergie funktioniert und wie sie zur nachhaltigen Energieversorgung beiträgt. Jetzt informieren!"
|
||||
image="https://www.e-werk-mittelbaden.de/sites/default/files/media_image/2024-12/DJI_20231105012629_0029_D-HDR.jpg"
|
||||
/>
|
||||
<blockquote>
|
||||
**Those who want to transport climate-friendly energy must also build in a climate-conscious way.**
|
||||
</blockquote>
|
||||
@@ -60,19 +53,12 @@ A well-considered dismantling does not begin with removal, but with a **forward-
|
||||
This is not only about **ecological aspects** – a planned dismantling also makes sense **economically**. Projects that are **systematically designed for dismantling** avoid high **disposal costs** and meet future **regulatory requirements** much more easily.
|
||||
Overall, it becomes clear: **Sustainability does not end at the grid connection.** It covers the **entire life cycle** – right up to the **last recycled cable**. Those who think about **infrastructure holistically** think it through **to the end**.
|
||||
In the following article, you can find out how, for example, wind turbines are recycled:
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.enbw.com/unternehmen/themen/windkraft/windrad-recycling.html"
|
||||
title="Recycling von Windrädern | EnBW"
|
||||
summary="Wie funktioniert das Recycling von Windrädern? Erfahren Sie mehr über Herausforderungen und die neuesten Methoden."
|
||||
image="https://www.enbw.com/media/image-proxy/1600x914,q70,focus60x67,zoom1.45/https://www.enbw.com/media/presse/images/newsroom/windenergie/rueckbau-windpark-hemme-3_1743678993586.jpg"
|
||||
/>
|
||||
"
|
||||
title="Recycling von Windrädern | EnBW"
|
||||
summary="Wie funktioniert das Recycling von Windrädern? Erfahren Sie mehr über Herausforderungen und die neuesten Methoden."
|
||||
image="https://www.enbw.com/media/image-proxy/1600x914,q70,focus60x67,zoom1.45/https://www.enbw.com/media/presse/images/newsroom/windenergie/rueckbau-windpark-hemme-3_1743678993586.jpg"
|
||||
/>
|
||||
## Reliable Grids Don’t Happen by Accident
|
||||
The **requirements** for today’s **energy grids** are constantly **increasing**. Especially for wind power projects realized in remote or structurally weak regions, a stable grid design is crucial. It is no longer enough to transmit power from A to B. The **infrastructure** must also work in unforeseen situations – during peak loads, maintenance or external disruptions.
|
||||
This **resilience** cannot be retrofitted. It must be considered right from the planning stage. **Grid architecture** that can flexibly respond to different operating situations is not a technical extra, but a fundamental part of **sustainable project development**. The ability to switch, use alternative routes, or throttle power without causing supply outages is particularly important.
|
||||
|
||||
@@ -45,16 +45,9 @@ Those planning sustainable projects don’t just need cables – they need a cab
|
||||
Cables are no side note – they are the nervous system of the energy transition. They connect **ideas with reality, sources with consumers, visions with feasibility**. And they do so discreetly, reliably, and for decades to come.
|
||||
Anyone thinking about **renewable energy** today should also consider what keeps that energy moving: **the cable industry.** It doesn’t just deliver copper and insulation – it provides solutions that make our **green future** possible in the first place.
|
||||
Find out how you can contribute to a sustainable energy supply in the following article.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://money-for-future.com/nachhaltige-energieversorgung-erneuerbare-energie"
|
||||
title="Nachhaltige Energieversorgung und erneuerbare Energie erklärt"
|
||||
summary="Nachhaltige Energieversorgung. Was kann ich tun, um die Energiewende voranzubringen? 7 Schritte zu einer nachhaltigen Lebensweise."
|
||||
image="https://money-for-future.com/wp-content/uploads/2022/01/Image-153-1.jpg"
|
||||
/>
|
||||
"
|
||||
title="Nachhaltige Energieversorgung und erneuerbare Energie erklärt"
|
||||
summary="Nachhaltige Energieversorgung. Was kann ich tun, um die Energiewende voranzubringen? 7 Schritte zu einer nachhaltigen Lebensweise."
|
||||
image="https://money-for-future.com/wp-content/uploads/2022/01/Image-153-1.jpg"
|
||||
/>
|
||||
|
||||
@@ -9,26 +9,12 @@ category: Kabel Technologie
|
||||
### What’s the Directory of Wind Energy?
|
||||
The <em>Directory of Wind Energy 2025</em> is the ultimate reference guide for the wind energy industry. With over 200 pages of insights, company listings, and industry contacts, it’s the resource planners, developers, and decision-makers use to connect with trusted suppliers and service providers. Covering everything from turbine manufacturers to certification companies, it’s a compact treasure trove of knowledge, both in print and online.
|
||||
Now, KLZ is part of this trusted network, making it even easier for industry professionals to find us.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
<VisualLinkPreview
|
||||
url="https://www.erneuerbareenergien.de/"
|
||||
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
|
||||
summary="Heft 01-2025"
|
||||
image="https://www.erneuerbareenergien.de/sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
|
||||
/>
|
||||
"
|
||||
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
|
||||
summary="Heft 01-2025"
|
||||
image="
|
||||
<VisualLinkPreview
|
||||
url="https://www.erneuerbareenergien.de/"
|
||||
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
|
||||
summary="Heft 01-2025"
|
||||
image="https://www.erneuerbareenergien.de/sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
|
||||
/>
|
||||
sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
|
||||
/>
|
||||
### Why we’re included
|
||||
Our medium voltage cables, like the **NA2XS(F)2Y**, have become essential in wind parks throughout Germany and the Netherlands. These cables play a critical role in transmitting electricity from wind turbines to substations, ensuring safe and reliable energy flow under the most demanding conditions.
|
||||
What sets us apart is more than the cables:
|
||||
|
||||
@@ -7,18 +7,11 @@ category: Kabel Technologie
|
||||
---
|
||||
# Securing the future with H1Z2Z2-K: Our solar cable for Intersolar 2025
|
||||
Around [Intersolar Europe](https://www.intersolar.de/start), the topic of photovoltaics is once again moving into the spotlight. A great reason to take a closer look at a special solar cable developed specifically for use in PV systems – robust, weather-resistant, and compliant with current standards.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://youtu.be/YbtdyvQFoVM"
|
||||
title="Intersolar Europe 2025 | Save The Date | May 7–9, 2025"
|
||||
summary="As the world’s leading exhibition for the solar industry, Intersolar Europe demonstrates the enormous vitality of the solar market. For more than 30 years, i…"
|
||||
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
|
||||
/>
|
||||
"
|
||||
title="Intersolar Europe 2025 | Save The Date | May 7–9, 2025"
|
||||
summary="As the world’s leading exhibition for the solar industry, Intersolar Europe demonstrates the enormous vitality of the solar market. For more than 30 years, i…"
|
||||
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
|
||||
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
|
||||
/>
|
||||
What lies behind the design, which selection criteria matter for solar cables, and why every detail counts in photovoltaic projects – that’s exactly what this article is about.
|
||||
## What is the H1Z2Z2-K 6mm² solar cable?
|
||||
@@ -113,16 +106,9 @@ The H1Z2Z2-K 6mm² stands for technical maturity and a consistent focus on profe
|
||||
What really stands out is its versatility: whether on rooftops, in underground installations, or in large-scale PV plants – the H1Z2Z2-K delivers reliability and an impressive service life. It makes a direct contribution to the economic efficiency and sustainability of solar systems.
|
||||
Further information, technical details, and ordering options can be found on the product page: 👉 [To the H1Z2Z2-K at KLZ](/products/solar-cables/h1z2z2-k/)
|
||||
All the key details about Intersolar Europe can be found here:
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.intersolar.de/messe-kompakt?ref=m5f53a666f3a2cb2fee160554-s65eec4739108db093b003a02-t1746004197-cf3c592e7"
|
||||
title="Intersolar Europe at a Glance"
|
||||
summary="Intersolar Europe | Exhibition Quick Facts | Date, Venue, Opening Hours, Exhibitors"
|
||||
image="https://www.intersolar.de/media/image/6311c9ee98bbc414b66305e2/750"
|
||||
/>
|
||||
"
|
||||
title="Intersolar Europe at a Glance"
|
||||
summary="Intersolar Europe | Exhibition Quick Facts | Date, Venue, Opening Hours, Exhibitors"
|
||||
image="https://www.intersolar.de/media/image/6311c9ee98bbc414b66305e2/750"
|
||||
/>
|
||||
|
||||
@@ -42,19 +42,12 @@ Choosing the right cable is a long-term investment that pays off in safety, cost
|
||||
<h4>The relevance of high-quality cables for a sustainable future</h4>
|
||||
In a world that is increasingly moving towards a carbon-neutral energy supply, first-class cables are helping to achieve these goals. Sustainable cables are made from recyclable materials that minimize environmental impact. They also support the integration of renewable energies into the power grid by ensuring that the electricity generated is transported efficiently and without losses.
|
||||
The right choice of cable is therefore not just a technical decision – it is a contribution to a more sustainable future. By using high-quality cables, the carbon footprint of infrastructure projects can be significantly reduced. This is an important step towards an environmentally friendly and energy-efficient society.A first-class cable is therefore more than just a technical component – it is a key to a more stable, greener and more efficient energy supply.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.konnworld.com/why-cable-quality-matters-the-impact-on-energy-efficiency-and-longevity"
|
||||
title="Why Cable Quality Matters: The Impact on Energy Efficiency and Longevity"
|
||||
summary="In the electrical systems that we have today, there’s no denying that cable quality is important in ensuring optimal performance and safety."
|
||||
image="https://www.konnworld.com/wp-content/uploads/2018/08/konn-b-logo.png"
|
||||
/>
|
||||
"
|
||||
title="Why Cable Quality Matters: The Impact on Energy Efficiency and Longevity"
|
||||
summary="In the electrical systems that we have today, there’s no denying that cable quality is important in ensuring optimal performance and safety."
|
||||
image="https://www.konnworld.com/wp-content/uploads/2018/08/konn-b-logo.png"
|
||||
/>
|
||||
<h4>Materials: What makes a cable durable and efficient?</h4>
|
||||
Choosing the right materials is crucial to making cables both durable and efficient. Two of the most common and important materials used in cables are copper and aluminum. They play a central role in the electrical conductivity and durability of cables.
|
||||
**The role of copper and aluminum**
|
||||
@@ -67,37 +60,23 @@ The demand for environmentally friendly materials is growing as more and more co
|
||||
- Aluminum recycling: Aluminum is also a highly recyclable material. The aluminum recycling process requires only about 5% of the energy needed to produce new aluminum. Many manufacturers are turning to recycled aluminum to improve their environmental footprint while increasing the efficiency of their cable products.
|
||||
- Biodegradable insulation: Another trend is the development of biodegradable or more environmentally friendly insulation materials. These materials not only reduce the use of toxic substances, but also help to minimize waste after the cable’s lifetime.In summary, choosing the right materials for cables is not only a factor in their longevity and efficiency, but also crucial for a sustainable future. Copper and aluminum offer excellent performance, but the focus on recycling and the search for more environmentally friendly alternatives is making the cable industry increasingly greener and more resource-efficient. So not only can a cable work efficiently today, it can also leave a smaller environmental footprint in the future.
|
||||
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://insights.regencysupply.com/pros-and-cons-of-copper-and-aluminum-wire"
|
||||
title="Pros and Cons of Copper and Aluminum Wire"
|
||||
summary="Copper wire and aluminum wire — which option is better? We weight the pros and cons."
|
||||
image="https://insights.regencysupply.com/hubfs/copper%20wire.jpg"
|
||||
/>
|
||||
"
|
||||
title="Pros and Cons of Copper and Aluminum Wire"
|
||||
summary="Copper wire and aluminum wire — which option is better? We weight the pros and cons."
|
||||
image="https://insights.regencysupply.com/hubfs/copper%20wire.jpg"
|
||||
/>
|
||||
<h4>Technology: Advanced designs for optimal performance</h4>
|
||||
Advanced cable technologies are critical to maximize the performance and safety of electrical systems, especially with regard to renewable energy sources. Two key technologies are insulation and sheathing, and specialized cables for renewable energy.
|
||||
**Insulation and sheathing: quality meets safety**<br />
|
||||
The insulation of a cable protects against short circuits and external influences such as moisture or mechanical damage. Materials such as PVC, PE and XLPE offer excellent protection and guarantee safe power transmission. The protective sheath protects the cable from UV radiation and extreme temperatures, which significantly extends its service life and increases safety.**Cables for renewable energy sources**<br />
|
||||
Solar and wind energy require specialized cables that can withstand extreme weather conditions and high loads. Solar cables must be UV resistant and suitable for DC systems, while wind power cables must be flexible and corrosion resistant to withstand the constant movement of rotor blades. These advanced cables enable the efficient and safe transportation of energy, which is crucial for the sustainability and economic viability of renewable energy.
|
||||
Overall, these technologies help maximize the efficiency and safety of cables and support a sustainable energy future.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.cables-unlimited.com/renewable-energy-cable-assemblies-weve-got-you-covered/"
|
||||
title="Renewable Energy Cable Assemblies — We’ve Got You Covered - Cables Unlimited Inc."
|
||||
summary="Cable assemblies used in renewable energy installations, what they are, their benefits and cable systems for renewable energy design factors."
|
||||
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got-You-Covered.jpg"
|
||||
/>
|
||||
"
|
||||
title="Renewable Energy Cable Assemblies — We’ve Got You Covered - Cables Unlimited Inc."
|
||||
summary="Cable assemblies used in renewable energy installations, what they are, their benefits and cable systems for renewable energy design factors."
|
||||
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got-You-Covered.jpg"
|
||||
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got You-Covered.jpg"
|
||||
/>
|
||||
<h4>Certifications and standards: a guarantee of quality</h4>
|
||||
The quality of cables is not only determined by their materials and technologies, but also by compliance with certifications and standards. These guarantee that cables are safe, efficient and durable. They play a decisive role in ensuring product quality and are an important criterion when selecting cables for various applications, particularly with regard to sustainability and environmental protection.**Important standards and seals for first-class cables**
|
||||
@@ -129,34 +108,20 @@ In a world that is increasingly focusing on sustainability, sustainability certi
|
||||
- Environmentally friendly production: Certificates such as ISO 14001 prove that manufacturers use environmentally friendly production processes that minimize energy consumption and waste.
|
||||
|
||||
The increasing importance of sustainability certificates is not only a response to legal requirements, but also a response to the growing awareness of consumers and companies who are looking for environmentally friendly products. In an industry increasingly dominated by green technologies, such certificates provide companies with a competitive advantage and consumers with the assurance that they are choosing responsibly produced products.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://www.flukenetworks.com/blog/cabling-chronicles/cabling-certification"
|
||||
title="Three Reasons Cabling Certification Is More Important Than Ever"
|
||||
summary="Every time you complete the installation of a structured cabling system, you can choose whether to certify it. All links in the system should be tested in some way to make sure that they’re connected properly, but is it necessary to measure and document the performance of every link?"
|
||||
image="https://www.flukenetworks.com/sites/default/files/blog/fn-dsx-8000_14a_w.jpg"
|
||||
/>
|
||||
"
|
||||
title="Three Reasons Cabling Certification Is More Important Than Ever"
|
||||
summary="Every time you complete the installation of a structured cabling system, you can choose whether to certify it. All links in the system should be tested in some way to make sure that they’re connected properly, but is it necessary to measure and document the performance of every link?"
|
||||
image="https://www.flukenetworks.com/sites/default/files/blog/fn-dsx-8000_14a_w.jpg"
|
||||
/>
|
||||
<h4>Conclusion: What really makes a first-class cable</h4>
|
||||
A first-class cable is characterized by a perfect balance between quality, efficiency and sustainability. Choosing the right cable is not just a question of technical requirements, but also an important step towards a more sustainable future. A high-quality cable ensures reliable performance and maximum efficiency, reduces energy loss and at the same time offers a high standard of safety.**Quality and efficiency**<br />
|
||||
A good cable is designed to work efficiently over the long term. Materials such as copper and aluminum ensure excellent conductivity, while modern insulation materials and protective layers extend the cable’s service life and protect it against external influences such as moisture and corrosion. This is particularly important in applications such as power transmission and the use of renewable energy, where efficiency and reliability are top priorities.
|
||||
**Sustainability**<br />
|
||||
In a world that is increasingly focusing on sustainability, environmental protection is playing an ever greater role when choosing a cable. Recyclability, sustainable production processes and certifications such as RoHS or recycling seals are decisive factors that determine the ecological footprint of a cable. Integrating these elements into cable production helps to minimize resource consumption and reduce waste, which contributes to a more environmentally friendly and resource-efficient future.
|
||||
<VisualLinkPreview
|
||||
url="
|
||||
<VisualLinkPreview
|
||||
url="https://sustainablebrands.com/read/evolving-infrastructure-wire-cable-prioritize-sustainability"
|
||||
title="Evolving Our Infrastructure Means the Wire and Cable Industry Must Prioritize Sustainability | Sustainable Brands"
|
||||
summary="To sustainably support the tremendous global demand for connectivity, collaboration is needed across the value chain to create solutions that enable more inf…"
|
||||
image="https://sb-web-assets.s3.amazonaws.com/production/46426/conversions/keyart-fbimg.jpg"
|
||||
/>
|
||||
"
|
||||
title="Evolving Our Infrastructure Means the Wire and Cable Industry Must Prioritize Sustainability | Sustainable Brands"
|
||||
summary="To sustainably support the tremendous global demand for connectivity, collaboration is needed across the value chain to create solutions that enable more inf…"
|
||||
image="https://sb-web-assets.s3.amazonaws.com/production/46426/conversions/keyart-fbimg.jpg"
|
||||
/>
|
||||
|
||||
@@ -43,34 +43,7 @@ collections:
|
||||
hidden: true
|
||||
schema:
|
||||
name: products_translations
|
||||
- collection: visual_feedback
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
collapse: open
|
||||
collection: visual_feedback
|
||||
color: '#002b49'
|
||||
display_template: '{{user_name}} | {{type}}: {{text}}'
|
||||
hidden: false
|
||||
icon: feedback
|
||||
singleton: false
|
||||
versioning: false
|
||||
schema:
|
||||
name: visual_feedback
|
||||
- collection: visual_feedback_comments
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
collapse: open
|
||||
collection: visual_feedback_comments
|
||||
color: '#002b49'
|
||||
display_template: '{{user_name}}: {{text}}'
|
||||
hidden: false
|
||||
icon: comment
|
||||
singleton: false
|
||||
versioning: false
|
||||
schema:
|
||||
name: visual_feedback_comments
|
||||
|
||||
fields:
|
||||
# contact_submissions
|
||||
- collection: contact_submissions
|
||||
@@ -235,244 +208,7 @@ fields:
|
||||
is_primary_key: true
|
||||
has_auto_increment: true
|
||||
|
||||
# visual_feedback (from current snapshot)
|
||||
- collection: visual_feedback
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: id
|
||||
hidden: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: id
|
||||
table: visual_feedback
|
||||
data_type: uuid
|
||||
is_nullable: false
|
||||
is_primary_key: true
|
||||
- collection: visual_feedback
|
||||
field: status
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
display: labels
|
||||
interface: select-dropdown
|
||||
sort: 2
|
||||
schema:
|
||||
name: status
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
default_value: open
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: type
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
display: labels
|
||||
interface: select-dropdown
|
||||
sort: 3
|
||||
schema:
|
||||
name: type
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: text
|
||||
type: text
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
interface: input-multiline
|
||||
sort: 4
|
||||
schema:
|
||||
name: text
|
||||
table: visual_feedback
|
||||
data_type: text
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: url
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
interface: input
|
||||
readonly: true
|
||||
sort: 5
|
||||
schema:
|
||||
name: url
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: user_info_group
|
||||
type: alias
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_info_group
|
||||
interface: group-detail
|
||||
sort: 6
|
||||
special:
|
||||
- alias
|
||||
- no-data
|
||||
- group
|
||||
- collection: visual_feedback
|
||||
field: user_name
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_name
|
||||
group: user_info_group
|
||||
interface: input
|
||||
sort: 1
|
||||
schema:
|
||||
name: user_name
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: user_identity
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_identity
|
||||
group: user_info_group
|
||||
interface: input
|
||||
readonly: true
|
||||
sort: 2
|
||||
schema:
|
||||
name: user_identity
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: technical_details_group
|
||||
type: alias
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: technical_details_group
|
||||
interface: group-detail
|
||||
sort: 7
|
||||
special:
|
||||
- alias
|
||||
- no-data
|
||||
- group
|
||||
- collection: visual_feedback
|
||||
field: selector
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: selector
|
||||
group: technical_details_group
|
||||
interface: input
|
||||
readonly: true
|
||||
sort: 1
|
||||
schema:
|
||||
name: selector
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: x
|
||||
type: float
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: x
|
||||
group: technical_details_group
|
||||
interface: input
|
||||
sort: 2
|
||||
schema:
|
||||
name: x
|
||||
table: visual_feedback
|
||||
data_type: real
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: 'y'
|
||||
type: float
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: 'y'
|
||||
group: technical_details_group
|
||||
interface: input
|
||||
sort: 3
|
||||
schema:
|
||||
name: 'y'
|
||||
table: visual_feedback
|
||||
data_type: real
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 8
|
||||
schema:
|
||||
name: date_created
|
||||
table: visual_feedback
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
is_nullable: true
|
||||
- collection: visual_feedback_comments
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: id
|
||||
hidden: true
|
||||
schema:
|
||||
name: id
|
||||
table: visual_feedback_comments
|
||||
data_type: uuid
|
||||
is_primary_key: true
|
||||
- collection: visual_feedback_comments
|
||||
field: feedback_id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: feedback_id
|
||||
interface: select-relational
|
||||
sort: 2
|
||||
schema:
|
||||
name: feedback_id
|
||||
table: visual_feedback_comments
|
||||
data_type: uuid
|
||||
- collection: visual_feedback_comments
|
||||
field: user_name
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: user_name
|
||||
interface: input
|
||||
sort: 3
|
||||
schema:
|
||||
name: user_name
|
||||
table: visual_feedback_comments
|
||||
data_type: character varying
|
||||
- collection: visual_feedback_comments
|
||||
field: text
|
||||
type: text
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: text
|
||||
interface: input-multiline
|
||||
sort: 4
|
||||
schema:
|
||||
name: text
|
||||
table: visual_feedback_comments
|
||||
data_type: text
|
||||
- collection: visual_feedback_comments
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
schema:
|
||||
name: date_created
|
||||
table: visual_feedback_comments
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
|
||||
|
||||
systemFields:
|
||||
- collection: directus_activity
|
||||
@@ -488,17 +224,6 @@ systemFields:
|
||||
schema:
|
||||
is_indexed: true
|
||||
|
||||
relations:
|
||||
- collection: visual_feedback_comments
|
||||
field: feedback_id
|
||||
related_collection: visual_feedback
|
||||
schema:
|
||||
column: feedback_id
|
||||
foreign_key_column: id
|
||||
foreign_key_table: visual_feedback
|
||||
table: visual_feedback_comments
|
||||
meta:
|
||||
many_collection: visual_feedback_comments
|
||||
many_field: feedback_id
|
||||
one_collection: visual_feedback
|
||||
one_field: null
|
||||
relations: []
|
||||
|
||||
|
||||
|
||||
@@ -25,9 +25,11 @@ services:
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=false"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
directus-cms:
|
||||
klz-cms:
|
||||
container_name: klz-cms-dev
|
||||
restart: "no"
|
||||
ports:
|
||||
- "8055:8055"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz.localhost}`)"
|
||||
@@ -39,5 +41,5 @@ services:
|
||||
klz-db:
|
||||
restart: "no"
|
||||
|
||||
gatekeeper:
|
||||
klz-gatekeeper:
|
||||
restart: "no"
|
||||
|
||||
@@ -15,51 +15,53 @@ services:
|
||||
- klz.localhost
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.middlewares=redirect-https"
|
||||
# HTTPS router (Standard)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
||||
|
||||
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathRegexp(`^/([a-z]{2}/)?api/og`) || PathRegexp(`^/([a-z]{2}/)?opengraph-image$`) || PathRegexp(`^/([a-z]{2}/)?blog/opengraph-image$`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.service=${PROJECT_NAME:-klz-cables}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.priority=2000"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/_img`) || PathPrefix(`/api/og`) || PathPrefix(`/de/api/og`) || PathPrefix(`/en/api/og`) || PathPrefix(`/logo-white.svg`) || PathPrefix(`/icon-white.svg`) || PathPrefix(`/opengraph-image`) || PathPrefix(`/de/opengraph-image`) || PathPrefix(`/en/opengraph-image`) || PathPrefix(`/blog/opengraph-image`) || PathPrefix(`/de/blog/opengraph-image`) || PathPrefix(`/en/blog/opengraph-image`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`) || PathRegexp(`.*\\.(svg|png|jpg|jpeg|gif|webp|ico|webm|mp4|map)$`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-klz-ratelimit,klz-forward,klz-compress}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
|
||||
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.scheme=http"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||
|
||||
# Middleware Definitions
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true"
|
||||
|
||||
# Forwarded Headers
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
|
||||
# Authentication Middleware (ForwardAuth)
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME:-klz-cables}-gatekeeper:3000/gatekeeper/api/verify"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
# Rate Limit Middleware
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50"
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ]
|
||||
interval: 15s
|
||||
@@ -67,14 +69,14 @@ services:
|
||||
retries: 3
|
||||
start_period: 45s
|
||||
|
||||
gatekeeper:
|
||||
klz-gatekeeper:
|
||||
profiles: [ "gatekeeper" ]
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
infra:
|
||||
aliases:
|
||||
- ${PROJECT_NAME:-klz-cables}-gatekeeper
|
||||
- ${PROJECT_NAME:-klz}-gatekeeper
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
@@ -88,15 +90,15 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=infra"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
directus-cms:
|
||||
klz-cms:
|
||||
image: registry.infra.mintel.me/mintel/directus:latest
|
||||
restart: unless-stopped
|
||||
command: [ "node", "cli.js", "start" ]
|
||||
@@ -112,11 +114,12 @@ services:
|
||||
DB_PORT: '5432'
|
||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
|
||||
WEBSOCKETS_ENABLED: 'true'
|
||||
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
|
||||
HOST: '0.0.0.0'
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
volumes:
|
||||
- ./directus/uploads:/directus/uploads
|
||||
@@ -127,14 +130,15 @@ services:
|
||||
disable: true
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.klz-production-cms.rule=Host(`cms.klz.localhost`)"
|
||||
- "traefik.http.routers.klz-production-cms.entrypoints=web"
|
||||
- "traefik.http.routers.klz-production-cms.priority=5000"
|
||||
- "traefik.http.routers.klz-production-cms.tls=false"
|
||||
- "traefik.http.routers.klz-production-cms.service=klz-production-cms-svc"
|
||||
- "traefik.http.services.klz-production-cms-svc.loadbalancer.server.port=8055"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz-cables.com}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.priority=5000"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.service=${PROJECT_NAME:-klz}-cms-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-cms-svc.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://${DIRECTUS_HOST:-cms.klz.localhost}"
|
||||
- "caddy=http://${DIRECTUS_HOST:-cms.klz-cables.com}"
|
||||
- "caddy.reverse_proxy={{upstreams 8055}}"
|
||||
klz-db:
|
||||
image: postgres:15-alpine
|
||||
@@ -144,11 +148,44 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
|
||||
volumes:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- default
|
||||
|
||||
klz-imgproxy:
|
||||
image: darthsim/imgproxy:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
extra_hosts:
|
||||
- "klz.localhost:host-gateway"
|
||||
- "cms.klz.localhost:host-gateway"
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
IMGPROXY_URL_MAPPING: "${IMGPROXY_URL_MAPPING:-http://klz.localhost/:http://klz-app:3000/,http://cms.klz.localhost/:http://klz-cms:8055/}"
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: 20
|
||||
IMGPROXY_IGNORE_SSL_ERRORS: "true"
|
||||
IMGPROXY_DEBUG: "true"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP router (local dev)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.service=${PROJECT_NAME:-klz}-imgproxy-svc"
|
||||
# HTTPS router (staging/prod)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls=${TRAEFIK_TLS:-false}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.service=${PROJECT_NAME:-klz}-imgproxy-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://img.${TRAEFIK_HOST:-klz.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 8080}}"
|
||||
|
||||
networks:
|
||||
default:
|
||||
@@ -158,3 +195,5 @@ networks:
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
external: true
|
||||
name: klz-cablescom_directus-db-data
|
||||
|
||||
@@ -36,7 +36,7 @@ function createConfig() {
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me',
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
},
|
||||
},
|
||||
|
||||
33
lib/imgproxy-loader.ts
Normal file
33
lib/imgproxy-loader.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getImgproxyUrl } from './imgproxy';
|
||||
|
||||
/**
|
||||
* Next.js Image Loader for imgproxy
|
||||
*
|
||||
* @param {Object} props - properties from Next.js Image component
|
||||
* @param {string} props.src - The source image URL
|
||||
* @param {number} props.width - The desired image width
|
||||
* @param {number} props.quality - The desired image quality (ignored for now as imgproxy handles it)
|
||||
*/
|
||||
export default function imgproxyLoader({
|
||||
src,
|
||||
width,
|
||||
_quality,
|
||||
}: {
|
||||
src: string;
|
||||
width: number;
|
||||
_quality?: number;
|
||||
}) {
|
||||
// Skip imgproxy for SVGs as they are vectors and don't benefit from resizing,
|
||||
// and often cause 404s if the source is not correctly resolvable by imgproxy.
|
||||
if (src.toLowerCase().endsWith('.svg')) {
|
||||
return src;
|
||||
}
|
||||
|
||||
// We use the width provided by Next.js for responsive images
|
||||
// Height is set to 0 to maintain aspect ratio
|
||||
return getImgproxyUrl(src, {
|
||||
width,
|
||||
resizing_type: 'fit',
|
||||
gravity: 'fv', // Use face-aware focusing (face detection)
|
||||
});
|
||||
}
|
||||
64
lib/imgproxy.ts
Normal file
64
lib/imgproxy.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Generates an imgproxy URL for a given source image and options.
|
||||
*
|
||||
* Documentation: https://docs.imgproxy.net/usage/processing
|
||||
*/
|
||||
|
||||
interface ImgproxyOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizing_type?: 'fit' | 'fill' | 'fill-down' | 'force' | 'auto';
|
||||
gravity?: string;
|
||||
enlarge?: boolean;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export function getImgproxyUrl(src: string, options: ImgproxyOptions = {}): string {
|
||||
// Use local proxy path which is rewritten in next.config.mjs
|
||||
const baseUrl = '/_img';
|
||||
|
||||
// Handle local paths or relative URLs
|
||||
let absoluteSrc = src;
|
||||
if (src.startsWith('/')) {
|
||||
const baseUrlForSrc =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : 'https://klz-cables.com');
|
||||
if (baseUrlForSrc) {
|
||||
absoluteSrc = `${baseUrlForSrc.replace(/\/$/, '')}${src}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Development mapping: Map local domains to internal Docker hostnames
|
||||
// so imgproxy can fetch images without SSL issues or external routing
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (absoluteSrc.includes('klz.localhost')) {
|
||||
absoluteSrc = absoluteSrc.replace(/^https?:\/\/klz\.localhost/, 'http://klz-app:3000');
|
||||
} else if (absoluteSrc.includes('cms.klz.localhost')) {
|
||||
absoluteSrc = absoluteSrc.replace(/^https?:\/\/cms\.klz\.localhost/, 'http://klz-cms:8055');
|
||||
}
|
||||
// Also handle direct container names if needed
|
||||
}
|
||||
|
||||
const {
|
||||
width = 0,
|
||||
height = 0,
|
||||
resizing_type = 'fit',
|
||||
gravity = 'sm', // Default to smart gravity
|
||||
enlarge = false,
|
||||
extension = '',
|
||||
} = options;
|
||||
|
||||
// Processing options
|
||||
// Format: /rs:<type>:<width>:<height>:<enlarge>/g:<gravity>
|
||||
const processingOptions = [
|
||||
`rs:${resizing_type}:${width}:${height}:${enlarge ? 1 : 0}`,
|
||||
`g:${gravity}`,
|
||||
].join('/');
|
||||
|
||||
// Using /unsafe/ with plain/ source URL format
|
||||
// plain/ format works reliably with imgproxy URL mapping
|
||||
// Format: <base_url>/unsafe/<options>/plain/<source_url>[@<extension>]
|
||||
const suffix = extension ? `@${extension}` : '';
|
||||
|
||||
return `${baseUrl}/unsafe/${processingOptions}/plain/${encodeURIComponent(absoluteSrc)}${suffix}`;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
||||
|
||||
// Add a timeout to prevent hanging requests
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -17,6 +17,9 @@ export class GotifyNotificationService implements NotificationService {
|
||||
const url = new URL('message', this.config.url);
|
||||
url.searchParams.set('token', this.config.token);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -27,8 +30,11 @@ export class GotifyNotificationService implements NotificationService {
|
||||
message,
|
||||
priority,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Gotify notification failed:', {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
numberOfRuns: 1,
|
||||
settings: {
|
||||
preset: 'desktop',
|
||||
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
||||
},
|
||||
},
|
||||
assert: {
|
||||
assertions: {
|
||||
'categories:performance': ['warn', { minScore: 0.9 }],
|
||||
'categories:accessibility': ['warn', { minScore: 0.9 }],
|
||||
'categories:best-practices': ['warn', { minScore: 0.9 }],
|
||||
'categories:seo': ['warn', { minScore: 0.9 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -58,12 +58,14 @@
|
||||
}
|
||||
},
|
||||
"Navigation": {
|
||||
"home": "Start",
|
||||
"menu": "Menü",
|
||||
"home": "KLZ Cables Startseite",
|
||||
"team": "Team",
|
||||
"products": "Produkte",
|
||||
"blog": "Blog",
|
||||
"contact": "Kontakt",
|
||||
"toggleMenu": "Menü umschalten"
|
||||
"toggleMenu": "Menü umschalten",
|
||||
"skipToContent": "Zum Inhalt springen"
|
||||
},
|
||||
"Footer": {
|
||||
"legal": "Rechtliches",
|
||||
@@ -120,7 +122,7 @@
|
||||
"quote": "Manchmal braucht es nur einen klaren Kopf und das richtige Kabel, um die Welt ein Stück besser zu machen.",
|
||||
"description": "Klaus ist der Fels in der Brandung – selbst wenn das Kabelchaos überhandnimmt. Mit jahrzehntelanger Erfahrung und einem stabilen Netzwerk sorgt er dafür, dass alles glatt läuft. Er denkt nicht nur in Lösungen, sondern bringt auch Humor und den nötigen Weitblick mit, um selbst komplexe Themen locker auf den Punkt zu bringen.",
|
||||
"linkedin": "Klaus' LinkedIn",
|
||||
"role": "Gründer & Visionär"
|
||||
"role": "Geschäftsführer"
|
||||
},
|
||||
"manifesto": {
|
||||
"title": "Unser Manifest",
|
||||
@@ -394,4 +396,4 @@
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,14 @@
|
||||
}
|
||||
},
|
||||
"Navigation": {
|
||||
"home": "Home",
|
||||
"menu": "Menu",
|
||||
"home": "KLZ Cables Home",
|
||||
"team": "Team",
|
||||
"products": "Products",
|
||||
"blog": "Blog",
|
||||
"contact": "Contact",
|
||||
"toggleMenu": "Toggle Menu"
|
||||
"toggleMenu": "Toggle Menu",
|
||||
"skipToContent": "Skip to content"
|
||||
},
|
||||
"Footer": {
|
||||
"legal": "Legal",
|
||||
@@ -120,7 +122,7 @@
|
||||
"quote": "Sometimes all it takes is a clear head and a good cable to make the world a little better.",
|
||||
"description": "Klaus is the man with the experience, bringing perspective and calm to the table—even when cable chaos threatens to take over. With impressive industry knowledge and a network as solid as our cables, he ensures everything runs smoothly. Klaus isn’t just a problem solver; he’s a strategic thinker who knows how to get to the point with a touch of humor.",
|
||||
"linkedin": "Check Klaus' LinkedIn",
|
||||
"role": "Founder & Visionary"
|
||||
"role": "Managing Director"
|
||||
},
|
||||
"manifesto": {
|
||||
"title": "Our manifesto",
|
||||
@@ -394,4 +396,4 @@
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Create the internationalization middleware
|
||||
const intlMiddleware = createMiddleware({
|
||||
@@ -20,9 +20,11 @@ export default function middleware(request: NextRequest) {
|
||||
pathname.startsWith('/errors') ||
|
||||
pathname.startsWith('/health') ||
|
||||
pathname.includes('/api/og') ||
|
||||
pathname.includes('opengraph-image')
|
||||
pathname.includes('opengraph-image') ||
|
||||
pathname.endsWith('sitemap.xml') ||
|
||||
pathname.endsWith('manifest.webmanifest')
|
||||
) {
|
||||
return;
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Build header object for logging
|
||||
@@ -93,6 +95,8 @@ export default function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf)$).*)',
|
||||
'/((?!api|_next/static|_next/image|_img|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
|
||||
'/(de|en)/:path*',
|
||||
'/(de|en)/:path*',
|
||||
],
|
||||
};
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -317,16 +317,10 @@ const nextConfig = {
|
||||
];
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'klz-cables.com',
|
||||
port: '',
|
||||
pathname: '/wp-content/uploads/**',
|
||||
},
|
||||
],
|
||||
loader: 'custom',
|
||||
loaderFile: './lib/imgproxy-loader.ts',
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: 'attachment',
|
||||
contentDispositionType: "attachment",
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
async rewrites() {
|
||||
@@ -341,11 +335,20 @@ const nextConfig = {
|
||||
|
||||
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
||||
|
||||
let imgproxyUrl = process.env.IMGPROXY_URL || 'https://img.infra.mintel.me';
|
||||
if (!imgproxyUrl.startsWith('http')) {
|
||||
imgproxyUrl = `https://${imgproxyUrl}`;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/cms/:path*',
|
||||
destination: `${directusUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/_img/:path*',
|
||||
destination: `${imgproxyUrl}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
14
package.json
14
package.json
@@ -12,6 +12,7 @@
|
||||
"@react-email/components": "^1.0.7",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/nextjs": "^10.38.0",
|
||||
"@types/recharts": "^2.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.0",
|
||||
@@ -32,6 +33,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-email": "^5.2.5",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^3.7.0",
|
||||
"require-in-the-middle": "^8.0.1",
|
||||
"resend": "^3.5.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
@@ -63,23 +65,27 @@
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"cheerio": "^1.2.0",
|
||||
"eslint": "^9.18.0",
|
||||
"happy-dom": "^20.6.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"lucide-react": "^0.563.0",
|
||||
"pa11y-ci": "^4.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"puppeteer": "^24.37.3",
|
||||
"remotion": "^4.0.421",
|
||||
"sass": "^1.97.1",
|
||||
"start-server-and-test": "^2.1.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App (Next.js): http://localhost:3000\\n📱 App (Traefik): http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up --build klz-app directus-cms klz-db gatekeeper",
|
||||
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d directus-cms klz-db gatekeeper",
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App (Next.js): http://localhost:3000\\n📱 App (Traefik): http://klz.localhost\\n🗄️ CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up --build klz-app klz-cms klz-db klz-gatekeeper",
|
||||
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d klz-cms klz-db klz-gatekeeper",
|
||||
"dev:local": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
@@ -88,6 +94,9 @@
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:og": "vitest run tests/og-image.test.ts",
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"check:mdx": "node scripts/validate-mdx.mjs",
|
||||
"check:a11y": "start-server-and-test start http://localhost:3000 'pa11y-ci'",
|
||||
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
||||
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
@@ -107,6 +116,7 @@
|
||||
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
|
||||
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
|
||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"pagespeed:audit": "./scripts/audit-local.sh",
|
||||
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||
"remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
|
||||
"remotion:preview": "remotion preview remotion/index.ts",
|
||||
|
||||
930
pnpm-lock.yaml
generated
930
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
51
scripts/audit-local.sh
Executable file
51
scripts/audit-local.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
# audit-local.sh
|
||||
# Runs a high-fidelity Lighthouse audit locally using the Docker production stack.
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting High-Fidelity Local Audit..."
|
||||
|
||||
# 1. Environment and Infrastructure
|
||||
export DOCKER_HOST="unix:///Users/marcmintel/.docker/run/docker.sock"
|
||||
export IMGPROXY_URL="http://img.klz.localhost"
|
||||
export NEXT_URL="http://klz.localhost"
|
||||
|
||||
docker network create infra 2>/dev/null || true
|
||||
docker volume create klz-cablescom_directus-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-cms klz-gatekeeper
|
||||
|
||||
# 3. Build and Start klz-app and klz-imgproxy in Production Mode
|
||||
echo "🏗️ Building and starting klz-app (Production)..."
|
||||
# We bypass the dev override by explicitly using the base compose file
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_URL \
|
||||
docker-compose -f docker-compose.yml up -d --build klz-app klz-imgproxy
|
||||
|
||||
# 4. Wait for application to be ready
|
||||
echo "⏳ Waiting for application to be healthy..."
|
||||
MAX_RETRIES=30
|
||||
RETRY_COUNT=0
|
||||
|
||||
until $(curl -s -f -o /dev/null "$NEXT_URL/health"); do
|
||||
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
|
||||
echo "❌ Error: App did not become healthy in time."
|
||||
exit 1
|
||||
fi
|
||||
echo " ...waiting for $NEXT_URL/health"
|
||||
sleep 2
|
||||
RETRY_COUNT=$((RETRY_COUNT+1))
|
||||
done
|
||||
|
||||
echo "✅ App is healthy at $NEXT_URL"
|
||||
|
||||
# 5. Run Lighthouse Audit
|
||||
echo "⚡ Executing Lighthouse CI..."
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=5 pnpm run pagespeed:test "$NEXT_URL"
|
||||
|
||||
echo "✨ Audit completed! Summary above."
|
||||
echo "💡 You can stop the production app with: docker-compose stop klz-app"
|
||||
@@ -13,7 +13,7 @@ PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//' | sed
|
||||
|
||||
case $ENV in
|
||||
local)
|
||||
CONTAINER=$(docker compose ps -q directus-cms)
|
||||
CONTAINER=$(docker compose ps -q klz-cms)
|
||||
if [ -z "$CONTAINER" ]; then
|
||||
echo "❌ Local directus container not found."
|
||||
exit 1
|
||||
|
||||
59
scripts/fix-directus-token.ts
Normal file
59
scripts/fix-directus-token.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import client, { ensureAuthenticated } from '../lib/directus';
|
||||
import { readUsers, updateUser } from '@directus/sdk';
|
||||
import { config } from '../lib/config';
|
||||
|
||||
async function fixToken() {
|
||||
console.log('🔑 Ensuring Directus Admin Token is set...');
|
||||
|
||||
try {
|
||||
// 1. Authenticate with credentials
|
||||
await ensureAuthenticated();
|
||||
|
||||
// 2. Find admin user
|
||||
const users = await client.request(
|
||||
readUsers({
|
||||
filter: {
|
||||
email: { _eq: config.directus.adminEmail },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
console.error(`❌ Could not find user with email ${config.directus.adminEmail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const admin = users[0];
|
||||
const targetToken = config.directus.token;
|
||||
|
||||
if (!targetToken) {
|
||||
console.error('❌ No DIRECTUS_API_TOKEN configured in environment.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (admin.token === targetToken) {
|
||||
console.log('✅ Token is already correctly set.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Update token
|
||||
console.log(`📡 Updating token for ${config.directus.adminEmail}...`);
|
||||
await client.request(
|
||||
updateUser(admin.id, {
|
||||
token: targetToken,
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('✨ Token successfully updated!');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fixing token:');
|
||||
if (error.errors) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
} else {
|
||||
console.error(error.message || error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fixToken();
|
||||
68
scripts/manual-schema-fix.ts
Normal file
68
scripts/manual-schema-fix.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import client, { ensureAuthenticated } from '../lib/directus';
|
||||
import { createCollection, createField } from '@directus/sdk';
|
||||
|
||||
async function setupSchema() {
|
||||
console.log('🏗️ Manually creating contact_submissions collection...');
|
||||
|
||||
try {
|
||||
// 1. Authenticate (using token from config)
|
||||
await ensureAuthenticated();
|
||||
|
||||
// 2. Create collection
|
||||
console.log('📡 Creating "contact_submissions" collection...');
|
||||
await client.request(
|
||||
createCollection({
|
||||
collection: 'contact_submissions',
|
||||
meta: {
|
||||
icon: 'contact_mail',
|
||||
color: '#002b49',
|
||||
display_template: '{{name}} | {{email}}',
|
||||
},
|
||||
schema: {
|
||||
name: 'contact_submissions',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Create fields
|
||||
console.log('📡 Creating fields for "contact_submissions"...');
|
||||
|
||||
// name
|
||||
await client.request(
|
||||
createField('contact_submissions', {
|
||||
field: 'name',
|
||||
type: 'string',
|
||||
meta: { interface: 'input' },
|
||||
}),
|
||||
);
|
||||
|
||||
// email
|
||||
await client.request(
|
||||
createField('contact_submissions', {
|
||||
field: 'email',
|
||||
type: 'string',
|
||||
meta: { interface: 'input' },
|
||||
}),
|
||||
);
|
||||
|
||||
// message
|
||||
await client.request(
|
||||
createField('contact_submissions', {
|
||||
field: 'message',
|
||||
type: 'text',
|
||||
meta: { interface: 'textarea' },
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('✨ Collection and fields created successfully!');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error creating schema:');
|
||||
if (error.errors) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
} else {
|
||||
console.error(error.message || error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupSchema();
|
||||
@@ -86,7 +86,7 @@ async function main() {
|
||||
|
||||
// Using a more robust way to execute and capture output
|
||||
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports
|
||||
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.settings.chromeFlags='--no-sandbox --disable-setuid-sandbox' --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert`;
|
||||
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert --config=config/lighthouserc.json`;
|
||||
|
||||
console.log(`💻 Executing LHCI...`);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ esac
|
||||
# Detect local container
|
||||
echo "🔍 Detecting local database..."
|
||||
# Use a more robust way to find the container if multiple projects exist locally
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q klz-directus-db)
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q klz-db)
|
||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||
echo "❌ Local klz-directus-db container not found. Is it running? (npm run dev)"
|
||||
exit 1
|
||||
|
||||
55
scripts/validate-mdx.mjs
Normal file
55
scripts/validate-mdx.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import { compile } from '@mdx-js/mdx';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
|
||||
const TARGET_DIRS = ['./data/blog', './data/products', './data/pages'];
|
||||
|
||||
function getAllFiles(dirPath, arrayOfFiles) {
|
||||
if (!fs.existsSync(dirPath)) return arrayOfFiles || [];
|
||||
|
||||
const files = fs.readdirSync(dirPath);
|
||||
|
||||
arrayOfFiles = arrayOfFiles || [];
|
||||
|
||||
files.forEach(function (file) {
|
||||
const fullPath = path.join(dirPath, file);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles);
|
||||
} else {
|
||||
arrayOfFiles.push(fullPath);
|
||||
}
|
||||
});
|
||||
|
||||
return arrayOfFiles;
|
||||
}
|
||||
|
||||
const allMdxFiles = TARGET_DIRS.flatMap(dir => getAllFiles(dir)).filter(file => file.endsWith('.mdx'));
|
||||
|
||||
console.log(`Found ${allMdxFiles.length} MDX files to validate...`);
|
||||
|
||||
let errorCount = 0;
|
||||
|
||||
for (const file of allMdxFiles) {
|
||||
const fileContent = fs.readFileSync(file, 'utf8');
|
||||
const { content } = matter(fileContent);
|
||||
|
||||
try {
|
||||
// Attempt to compile MDX content
|
||||
await compile(content);
|
||||
} catch (err) {
|
||||
console.error(`\x1b[31mError in ${file}:\x1b[0m`);
|
||||
console.error(err.message);
|
||||
if (err.line && err.column) {
|
||||
console.error(`At line ${err.line}, column ${err.column}`);
|
||||
}
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
console.error(`\n\x1b[31mValidation failed: ${errorCount} errors found.\x1b[0m`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n\x1b[32mAll MDX files are valid.\x1b[0m');
|
||||
}
|
||||
163
scripts/wcag-sitemap.ts
Normal file
163
scripts/wcag-sitemap.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* WCAG Audit Script
|
||||
*
|
||||
* 1. Fetches sitemap.xml from the target URL
|
||||
* 2. Extracts all URLs
|
||||
* 3. Runs pa11y-ci on those URLs
|
||||
*/
|
||||
|
||||
const targetUrl =
|
||||
process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'https://testing.klz-cables.com';
|
||||
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
|
||||
console.log(`📊 Limit: ${limit} pages\n`);
|
||||
|
||||
try {
|
||||
// 1. Fetch Sitemap
|
||||
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||
|
||||
const response = await axios.get(sitemapUrl, {
|
||||
headers: {
|
||||
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
||||
},
|
||||
validateStatus: (status) => status < 400,
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||
let urls = $('url loc')
|
||||
.map((i, el) => $(el).text())
|
||||
.get();
|
||||
|
||||
// Cleanup, filter and normalize domains to targetUrl
|
||||
const urlPattern = /https?:\/\/[^\/]+/;
|
||||
urls = [...new Set(urls)]
|
||||
.filter((u) => u.startsWith('http'))
|
||||
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||
.sort();
|
||||
|
||||
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
|
||||
|
||||
if (urls.length === 0) {
|
||||
console.error('❌ No URLs found in sitemap. Is the site up?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (urls.length > limit) {
|
||||
console.log(
|
||||
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
||||
);
|
||||
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl);
|
||||
const others = urls.filter((u) => !home.includes(u));
|
||||
urls = [...home, ...others.slice(0, limit - home.length)];
|
||||
}
|
||||
|
||||
console.log(`🧪 Pages to be tested:`);
|
||||
urls.forEach((u) => console.log(` - ${u}`));
|
||||
|
||||
// 2. Prepare pa11y-ci config
|
||||
const baseConfigPath = path.join(process.cwd(), '.pa11yci.json');
|
||||
let baseConfig: any = { defaults: {} };
|
||||
if (fs.existsSync(baseConfigPath)) {
|
||||
baseConfig = JSON.parse(fs.readFileSync(baseConfigPath, 'utf8'));
|
||||
}
|
||||
|
||||
// Extract domain for cookie
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domain = urlObj.hostname;
|
||||
|
||||
// Update config with discovered URLs and gatekeeper cookie
|
||||
const tempConfig = {
|
||||
...baseConfig,
|
||||
defaults: {
|
||||
...baseConfig.defaults,
|
||||
actions: [
|
||||
`set cookie klz_gatekeeper_session=${gatekeeperPassword} domain=${domain} path=/`,
|
||||
...(baseConfig.defaults?.actions || []),
|
||||
],
|
||||
timeout: 60000, // Increase timeout for slower pages
|
||||
},
|
||||
urls: urls,
|
||||
};
|
||||
|
||||
const tempConfigPath = path.join(process.cwd(), '.pa11yci.temp.json');
|
||||
const reportPath = path.join(process.cwd(), '.pa11yci-report.json');
|
||||
fs.writeFileSync(tempConfigPath, JSON.stringify(tempConfig, null, 2));
|
||||
|
||||
// 3. Execute pa11y-ci
|
||||
console.log(`\n💻 Executing pa11y-ci...`);
|
||||
const pa11yCommand = `npx pa11y-ci --config .pa11yci.temp.json --reporter json > .pa11yci-report.json`;
|
||||
|
||||
try {
|
||||
execSync(pa11yCommand, {
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (err: any) {
|
||||
// pa11y-ci exits with non-zero if issues are found, which is expected
|
||||
}
|
||||
|
||||
// 4. Summarize Results
|
||||
if (fs.existsSync(reportPath)) {
|
||||
const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
||||
console.log(`\n📊 WCAG Audit Summary:\n`);
|
||||
|
||||
const summaryTable = Object.keys(reportData.results).map((url) => {
|
||||
const results = reportData.results[url];
|
||||
const errors = results.filter((r: any) => r.type === 'error').length;
|
||||
const warnings = results.filter((r: any) => r.type === 'warning').length;
|
||||
const notices = results.filter((r: any) => r.type === 'notice').length;
|
||||
|
||||
// Clean URL for display
|
||||
const displayUrl = url.replace(targetUrl, '') || '/';
|
||||
|
||||
return {
|
||||
URL: displayUrl.length > 50 ? displayUrl.substring(0, 47) + '...' : displayUrl,
|
||||
Errors: errors,
|
||||
Warnings: warnings,
|
||||
Notices: notices,
|
||||
Status: errors === 0 ? '✅' : '❌',
|
||||
};
|
||||
});
|
||||
|
||||
console.table(summaryTable);
|
||||
|
||||
const totalErrors = summaryTable.reduce((acc, curr) => acc + curr.Errors, 0);
|
||||
const totalPages = summaryTable.length;
|
||||
const cleanPages = summaryTable.filter((p) => p.Errors === 0).length;
|
||||
|
||||
console.log(`\n📈 Result: ${cleanPages}/${totalPages} pages are error-free.`);
|
||||
if (totalErrors > 0) {
|
||||
console.log(` Total Errors discovered: ${totalErrors}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✨ WCAG Audit completed!`);
|
||||
} catch (error: any) {
|
||||
console.error(`\n❌ Error during WCAG Audit:`);
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(`Status: ${error.response?.status}`);
|
||||
console.error(`URL: ${error.config?.url}`);
|
||||
} else {
|
||||
console.error(error.message);
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
['.pa11yci.temp.json', '.pa11yci-report.json'].forEach((f) => {
|
||||
const p = path.join(process.cwd(), f);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user