Compare commits
13 Commits
v1.0.12
...
v1.1.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| f0672600e4 | |||
| 61daeaf03f | |||
| 9d935ce03b | |||
| 9fab9a4536 | |||
| 291f6aa34f | |||
| a111851176 | |||
| 64c6873735 | |||
| 0d39beef70 | |||
| 95d0d094e1 | |||
| 38cf6a8d75 | |||
| ea55580e18 | |||
| df2dd23206 | |||
| 374fcc9689 |
@@ -57,6 +57,9 @@ SENTRY_DSN=
|
|||||||
IMAGE_TAG=latest
|
IMAGE_TAG=latest
|
||||||
TRAEFIK_HOST=klz-cables.com
|
TRAEFIK_HOST=klz-cables.com
|
||||||
ENV_FILE=.env
|
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
|
# Varnish Configuration
|
||||||
|
|||||||
@@ -34,3 +34,9 @@ jobs:
|
|||||||
|
|
||||||
- name: 🧪 QA Checks
|
- name: 🧪 QA Checks
|
||||||
run: pnpm check:mdx && 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
|
||||||
|
|||||||
@@ -406,11 +406,79 @@ jobs:
|
|||||||
run: pnpm run check:og
|
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:
|
notifications:
|
||||||
name: 🔔 Notify
|
name: 🔔 Notify
|
||||||
needs: [prepare, deploy, smoke_test]
|
needs: [prepare, deploy, smoke_test, lighthouse]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,11 @@ node_modules
|
|||||||
.next
|
.next
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Lighthouse CI
|
||||||
|
.lighthouseci/
|
||||||
|
lighthouserc.cjs
|
||||||
|
.lighthouserc.json
|
||||||
|
|
||||||
# Directus
|
# Directus
|
||||||
directus/uploads
|
directus/uploads
|
||||||
!directus/extensions/
|
!directus/extensions/
|
||||||
|
|||||||
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,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
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: {
|
openGraph: {
|
||||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
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">
|
<div className="bg-neutral-light min-h-screen">
|
||||||
{/* Hero Section - Immersive Magazine Feel */}
|
{/* Hero Section - Immersive Magazine Feel */}
|
||||||
<Reveal>
|
<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 && (
|
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||||
<>
|
<>
|
||||||
<Image
|
<Image
|
||||||
@@ -101,7 +101,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</article>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Section className="bg-neutral-light py-12 md:py-28">
|
<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) => (
|
{remainingPosts.map((post, idx) => (
|
||||||
<Reveal key={post.slug} delay={idx * 100}>
|
<Reveal key={post.slug} delay={idx * 100}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
<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 && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||||
<Image
|
<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">
|
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||||
{t('info.howToReachUs')}
|
{t('info.howToReachUs')}
|
||||||
</Heading>
|
</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="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">
|
<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
|
<svg
|
||||||
@@ -197,7 +197,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</address>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
<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 Footer from '@/components/Footer';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
import SkipLink from '@/components/SkipLink';
|
||||||
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
|
|
||||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||||
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||||
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
|
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
|
||||||
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
|
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
|
||||||
|
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
@@ -23,22 +24,37 @@ const inter = Inter({
|
|||||||
variable: '--font-inter',
|
variable: '--font-inter',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(props: {
|
||||||
metadataBase: new URL(SITE_URL),
|
params: Promise<{ locale: string }>;
|
||||||
icons: {
|
}): Promise<Metadata> {
|
||||||
icon: [
|
const params = await props.params;
|
||||||
{ url: '/favicon.ico', sizes: 'any' },
|
const { locale } = params;
|
||||||
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
|
||||||
],
|
return {
|
||||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
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 = {
|
export const viewport: Viewport = {
|
||||||
width: 'device-width',
|
width: 'device-width',
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 5,
|
||||||
userScalable: false,
|
userScalable: true,
|
||||||
viewportFit: 'cover',
|
viewportFit: 'cover',
|
||||||
themeColor: '#001a4d',
|
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 {
|
} catch {
|
||||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -100,18 +117,22 @@ export default async function Layout(props: {
|
|||||||
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
||||||
<RecordModeProvider isEnabled={recordModeEnabled}>
|
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||||
<RecordModeVisuals>
|
<RecordModeVisuals>
|
||||||
|
<SkipLink />
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<Header />
|
<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 />
|
<Footer />
|
||||||
</RecordModeVisuals>
|
</RecordModeVisuals>
|
||||||
|
|
||||||
<CMSConnectivityNotice />
|
<CMSConnectivityNotice />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<AnalyticsShell />
|
||||||
<AnalyticsProvider />
|
|
||||||
<ScrollDepthTracker />
|
|
||||||
</Suspense>
|
|
||||||
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
||||||
</RecordModeProvider>
|
</RecordModeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ export async function generateMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const title = t('title') || 'KLZ Cables';
|
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 {
|
return {
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ProductTabs from '@/components/ProductTabs';
|
|||||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||||
import RelatedProducts from '@/components/RelatedProducts';
|
import RelatedProducts from '@/components/RelatedProducts';
|
||||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
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 { getDatasheetPath } from '@/lib/datasheets';
|
||||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||||
@@ -239,57 +239,59 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
|
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"
|
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">
|
<Card tag="article" className="premium-card-reset">
|
||||||
{product.frontmatter.images?.[0] && (
|
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||||
<>
|
{product.frontmatter.images?.[0] && (
|
||||||
<Image
|
<>
|
||||||
src={product.frontmatter.images[0]}
|
<Image
|
||||||
alt={product.frontmatter.title}
|
src={product.frontmatter.images[0]}
|
||||||
fill
|
alt={product.frontmatter.title}
|
||||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
fill
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
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" />
|
{/* 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>
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="p-8 md:p-10">
|
||||||
{product.frontmatter.categories.map((cat, i) => (
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<span
|
{product.frontmatter.categories.map((cat, i) => (
|
||||||
key={i}
|
<span
|
||||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
key={i}
|
||||||
>
|
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||||
{cat}
|
>
|
||||||
|
{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>
|
</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>
|
</div>
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
</Card>
|
||||||
{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>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
{/* 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">
|
<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">
|
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
||||||
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
<div className="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" />
|
<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>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
|
|
||||||
{/* Legacy Section - Immersive Background */}
|
{/* Legacy Section - Immersive Background */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -217,7 +217,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Klaus Mintel Section - Reversed Split Layout */}
|
{/* 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">
|
<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">
|
<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
|
<Image
|
||||||
@@ -264,7 +264,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
|
|
||||||
{/* Manifesto Section - Modern Grid */}
|
{/* Manifesto Section - Modern Grid */}
|
||||||
<Section className="bg-white text-primary py-16 md:py-28">
|
<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>
|
||||||
</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) => (
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||||
<div
|
<li
|
||||||
key={idx}
|
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"
|
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">
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||||
{t(`manifesto.items.${idx}.description`)}
|
{t(`manifesto.items.${idx}.description`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -65,9 +65,28 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ status: 'ok' });
|
return NextResponse.json({ status: 'ok' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to proxy analytics request', {
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
error: (error as Error).message,
|
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') {
|
if (status === 'success') {
|
||||||
return (
|
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">
|
<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
|
<svg
|
||||||
className="w-10 h-10 text-primary-dark"
|
className="w-10 h-10 text-primary-dark"
|
||||||
@@ -93,7 +97,11 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
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">
|
<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
|
<svg
|
||||||
className="w-10 h-10 text-destructive-foreground"
|
className="w-10 h-10 text-destructive-foreground"
|
||||||
@@ -132,40 +140,43 @@ export default function ContactForm() {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
<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">
|
<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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="contact-name"
|
||||||
name="name"
|
name="name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
onFocus={() => handleFocus('name')}
|
onFocus={() => handleFocus('contact-name')}
|
||||||
|
aria-label={t('form.name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 md:space-y-2">
|
<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
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="contact-email"
|
||||||
name="email"
|
name="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
enterKeyHint="next"
|
enterKeyHint="next"
|
||||||
placeholder={t('form.emailPlaceholder')}
|
placeholder={t('form.emailPlaceholder')}
|
||||||
onFocus={() => handleFocus('email')}
|
onFocus={() => handleFocus('contact-email')}
|
||||||
|
aria-label={t('form.email')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
<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
|
<Textarea
|
||||||
id="message"
|
id="contact-message"
|
||||||
name="message"
|
name="message"
|
||||||
rows={4}
|
rows={4}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
placeholder={t('form.messagePlaceholder')}
|
placeholder={t('form.messagePlaceholder')}
|
||||||
onFocus={() => handleFocus('message')}
|
onFocus={() => handleFocus('contact-message')}
|
||||||
|
aria-label={t('form.message')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -69,7 +70,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
|
|
||||||
{/* Arrow Icon */}
|
{/* 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">
|
<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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/logo-white.svg"
|
src="/logo-white.svg"
|
||||||
alt={t('products')}
|
alt="KLZ Vertriebs GmbH"
|
||||||
width={150}
|
width={150}
|
||||||
height={40}
|
height={40}
|
||||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||||
@@ -67,9 +67,9 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Links Columns */}
|
{/* Links Columns */}
|
||||||
<div className="lg:col-span-2">
|
<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')}
|
{t('legal')}
|
||||||
</h4>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
@@ -120,9 +120,9 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
<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')}
|
{t('company')}
|
||||||
</h4>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
@@ -189,9 +189,9 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Recent Posts Column */}
|
{/* Recent Posts Column */}
|
||||||
<div className="lg:col-span-4">
|
<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')}
|
{t('recentPosts')}
|
||||||
</h4>
|
</h3>
|
||||||
<ul className="space-y-6 list-none m-0 p-0">
|
<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">
|
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
||||||
{post.title}
|
{post.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-white/40 uppercase tracking-widest">
|
<span className="text-xs text-white/70 uppercase tracking-widest">
|
||||||
{t('readArticle')} →
|
{t('readArticle')} →
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -240,7 +240,7 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { cn } from './ui';
|
import { cn } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
@@ -17,6 +17,8 @@ export default function Header() {
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
const currentLocale = pathname.split('/')[1] || 'en';
|
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
|
||||||
|
// Prevent scroll when mobile menu is open and handle focus trap
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobileMenuOpen) {
|
if (isMobileMenuOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
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 {
|
} else {
|
||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
}
|
}
|
||||||
@@ -227,6 +272,8 @@ export default function Header() {
|
|||||||
textColorClass,
|
textColorClass,
|
||||||
)}
|
)}
|
||||||
aria-label={t('toggleMenu')}
|
aria-label={t('toggleMenu')}
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
|
aria-controls="mobile-menu"
|
||||||
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
|
||||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
@@ -288,8 +335,13 @@ export default function Header() {
|
|||||||
? 'opacity-100 translate-y-0'
|
? 'opacity-100 translate-y-0'
|
||||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
: '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"
|
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
|
||||||
initial="closed"
|
initial="closed"
|
||||||
animate={isMobileMenuOpen ? 'open' : '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 />
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.nav>
|
||||||
</div>
|
</div>
|
||||||
</motion.header>
|
</motion.header>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
@@ -19,6 +19,8 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
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]);
|
}, [isOpen, currentIndex, updateUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') handleClose();
|
if (e.key === 'Escape') handleClose();
|
||||||
if (e.key === 'ArrowLeft') prevImage();
|
if (e.key === 'ArrowLeft') prevImage();
|
||||||
if (e.key === 'ArrowRight') nextImage();
|
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
|
// Lock scroll
|
||||||
@@ -101,7 +141,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{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
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -116,6 +160,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.5 }}
|
exit={{ opacity: 0, scale: 0.5 }}
|
||||||
transition={{ delay: 0.1, duration: 0.4 }}
|
transition={{ delay: 0.1, duration: 0.4 }}
|
||||||
|
ref={closeButtonRef}
|
||||||
onClick={handleClose}
|
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"
|
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"
|
aria-label="Close lightbox"
|
||||||
|
|||||||
@@ -14,25 +14,30 @@ interface ProductSidebarProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductSidebar({ productName, productImage, datasheetPath, className }: ProductSidebarProps) {
|
export default function ProductSidebar({
|
||||||
|
productName,
|
||||||
|
productImage,
|
||||||
|
datasheetPath,
|
||||||
|
className,
|
||||||
|
}: ProductSidebarProps) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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-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">
|
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
||||||
{/* Background Accent - Saturated Blue Glow */}
|
{/* 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" />
|
<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 */}
|
{/* Product Thumbnail with Reflection */}
|
||||||
{productImage && (
|
{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 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">
|
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
||||||
<Image
|
<Image
|
||||||
src={productImage}
|
src={productImage}
|
||||||
alt={productName}
|
alt={productName}
|
||||||
fill
|
fill
|
||||||
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
||||||
/>
|
/>
|
||||||
{/* Subtle Reflection Overlay */}
|
{/* 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">
|
<h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
|
||||||
{t('requestQuote')}
|
{t('requestQuote')}
|
||||||
</h3>
|
</h3>
|
||||||
<Scribble
|
<Scribble
|
||||||
variant="underline"
|
variant="underline"
|
||||||
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
||||||
color="var(--color-accent)"
|
color="var(--color-accent)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,16 +62,14 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-neutral-light/50">
|
<div className="p-6 bg-neutral-light/50">
|
||||||
<RequestQuoteForm productName={productName} />
|
<RequestQuoteForm productName={productName} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Datasheet Download */}
|
{/* Datasheet Download */}
|
||||||
{datasheetPath && (
|
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
</aside>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
const { technicalItems = [], voltageTables = [] } = data;
|
const { technicalItems = [], voltageTables = [] } = data;
|
||||||
|
|
||||||
const toggleTable = (idx: number) => {
|
const toggleTable = (idx: number) => {
|
||||||
setExpandedTables(prev => ({
|
setExpandedTables((prev) => ({
|
||||||
...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">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
||||||
{technicalItems.map((item, idx) => (
|
{technicalItems.map((item, idx) => (
|
||||||
<div key={idx} className="flex flex-col group">
|
<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">
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -61,29 +68,38 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
{voltageTables.map((table, idx) => {
|
{voltageTables.map((table, idx) => {
|
||||||
const isExpanded = expandedTables[idx];
|
const isExpanded = expandedTables[idx];
|
||||||
const hasManyRows = table.rows.length > 10;
|
const hasManyRows = table.rows.length > 10;
|
||||||
|
|
||||||
return (
|
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">
|
<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" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
|
{table.voltageLabel !== 'Voltage unknown' &&
|
||||||
? table.voltageLabel
|
table.voltageLabel !== 'Spannung unbekannt'
|
||||||
|
? table.voltageLabel
|
||||||
: 'Technical Specifications'}
|
: 'Technical Specifications'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{table.metaItems.length > 0 && (
|
{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">
|
<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) => (
|
{table.metaItems.map((item, mIdx) => (
|
||||||
<div key={mIdx}>
|
<div key={mIdx}>
|
||||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">{item.label}</dt>
|
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||||
<dd className="font-bold text-primary">{item.value} {item.unit}</dd>
|
{item.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-bold text-primary">
|
||||||
|
{item.value} {item.unit}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<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 ${
|
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]'
|
!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">
|
<table className="min-w-full border-separate border-spacing-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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.
|
Config.
|
||||||
</th>
|
</th>
|
||||||
{table.columns.map((col, cIdx) => (
|
{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}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -108,7 +131,10 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
{row.configuration}
|
{row.configuration}
|
||||||
</td>
|
</td>
|
||||||
{row.cells.map((cell, cellIdx) => (
|
{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}
|
{cell}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
@@ -127,6 +153,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
<div className="mt-8 flex justify-center">
|
<div className="mt-8 flex justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleTable(idx)}
|
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"
|
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')}
|
{isExpanded ? t('showLess') : t('showMore')}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export default async function RelatedProducts({
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
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="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">
|
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||||
<svg
|
<svg
|
||||||
@@ -118,7 +122,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
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="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">
|
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||||
<svg
|
<svg
|
||||||
@@ -158,25 +166,27 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="quote-email"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
onFocus={() => handleFocus('email')}
|
onFocus={() => handleFocus('quote-email')}
|
||||||
placeholder={t('email')}
|
placeholder={t('email')}
|
||||||
|
aria-label={t('email')}
|
||||||
className="h-9 text-xs !mt-0"
|
className="h-9 text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
<Textarea
|
<Textarea
|
||||||
id="request"
|
id="quote-request"
|
||||||
required
|
required
|
||||||
rows={3}
|
rows={3}
|
||||||
value={request}
|
value={request}
|
||||||
onChange={(e) => setRequest(e.target.value)}
|
onChange={(e) => setRequest(e.target.value)}
|
||||||
onFocus={() => handleFocus('request')}
|
onFocus={() => handleFocus('quote-request')}
|
||||||
placeholder={t('message')}
|
placeholder={t('message')}
|
||||||
|
aria-label={t('message')}
|
||||||
className="text-xs !mt-0"
|
className="text-xs !mt-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,31 +18,31 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 1.8,
|
duration: 1.8,
|
||||||
ease: "easeInOut",
|
ease: 'easeInOut',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (variant === 'circle') {
|
if (variant === 'circle') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={cn("absolute pointer-events-none", className)}
|
className={cn('absolute pointer-events-none', className)}
|
||||||
role="presentation"
|
aria-hidden="true"
|
||||||
viewBox="0 0 800 350"
|
viewBox="0 0 800 350"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<motion.path
|
<motion.path
|
||||||
variants={pathVariants}
|
variants={pathVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||||
strokeLinejoin="miter"
|
strokeLinejoin="miter"
|
||||||
fillOpacity="0"
|
fillOpacity="0"
|
||||||
strokeMiterlimit="4"
|
strokeMiterlimit="4"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeOpacity="1"
|
strokeOpacity="1"
|
||||||
strokeWidth="20"
|
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"
|
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>
|
</svg>
|
||||||
@@ -51,20 +51,20 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
|||||||
|
|
||||||
if (variant === 'underline') {
|
if (variant === 'underline') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={cn("absolute pointer-events-none", className)}
|
className={cn('absolute pointer-events-none', className)}
|
||||||
role="presentation"
|
aria-hidden="true"
|
||||||
viewBox="-400 -55 730 60"
|
viewBox="-400 -55 730 60"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<motion.path
|
<motion.path
|
||||||
variants={pathVariants}
|
variants={pathVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true }}
|
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"
|
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}
|
stroke={color}
|
||||||
strokeWidth="20"
|
strokeWidth="20"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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 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 className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative accent */}
|
{/* 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="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="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">
|
<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'}
|
{isDe ? 'Lösungen' : 'Solutions'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight">
|
<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>
|
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
|
<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.'
|
? '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>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
<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 ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
|
||||||
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
|
||||||
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects',
|
isDe
|
||||||
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards'
|
? '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) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="flex items-center gap-4 text-white/80">
|
<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">
|
<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">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{item}</span>
|
<span className="text-sm font-medium">{item}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
|
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/contact`}
|
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"
|
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'}
|
{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">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
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>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-white/50 text-sm font-medium">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,24 +32,24 @@ export default function Experience() {
|
|||||||
<p className="pl-9">{t('p2')}</p>
|
<p className="pl-9">{t('p2')}</p>
|
||||||
</div>
|
</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="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')}
|
{t('certifiedQuality')}
|
||||||
</div>
|
</dt>
|
||||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||||
{t('vdeApproved')}
|
{t('vdeApproved')}
|
||||||
</div>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
|
<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')}
|
{t('fullSpectrum')}
|
||||||
</div>
|
</dt>
|
||||||
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
|
||||||
{t('solutionsRange')}
|
{t('solutionsRange')}
|
||||||
</div>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export default function GallerySection() {
|
|||||||
{images.map((src, idx) => (
|
{images.map((src, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${t('alt')} ${idx + 1}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('photo', idx.toString());
|
params.set('photo', idx.toString());
|
||||||
@@ -47,6 +49,7 @@ export default function GallerySection() {
|
|||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
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 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" />
|
<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: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.12,
|
staggerChildren: 0.1,
|
||||||
delayChildren: 0.4,
|
delayChildren: 0.1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const headingVariants = {
|
const headingVariants = {
|
||||||
hidden: { opacity: 0, y: 60, scale: 0.85 },
|
hidden: { opacity: 1, y: 30, scale: 0.95 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
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;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export default function HeroIllustration() {
|
|||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">
|
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default function ProductCategories() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{categories.map((category, idx) => (
|
{categories.map((category, idx) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -32,60 +32,69 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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) => (
|
{recentPosts.map((post) => (
|
||||||
<Link key={post.slug} href={`/${locale}/blog/${post.slug}`} className="group block">
|
<li key={post.slug}>
|
||||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
|
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
|
||||||
{post.frontmatter.featuredImage && (
|
<Card
|
||||||
<div className="relative h-64 overflow-hidden">
|
tag="article"
|
||||||
<Image
|
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
|
||||||
src={post.frontmatter.featuredImage}
|
>
|
||||||
alt={post.frontmatter.title}
|
{post.frontmatter.featuredImage && (
|
||||||
fill
|
<div className="relative h-64 overflow-hidden">
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
<Image
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
src={post.frontmatter.featuredImage}
|
||||||
/>
|
alt={post.frontmatter.title}
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
fill
|
||||||
{post.frontmatter.category && (
|
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
{post.frontmatter.category}
|
loading="lazy"
|
||||||
</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"
|
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
</Link>
|
||||||
</Link>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,32 +17,54 @@ export default function WhyChooseUs() {
|
|||||||
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
|
||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</p>
|
</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) => (
|
{[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">
|
<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">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
|
<span className="font-bold text-primary-dark text-base md:text-base">
|
||||||
</div>
|
{t(`features.${i}`)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</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) => (
|
{[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">
|
<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>
|
</div>
|
||||||
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
|
<h3 className="text-xl font-bold mb-4 text-primary-dark">
|
||||||
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
|
{t(`items.${idx}.title`)}
|
||||||
</div>
|
</h3>
|
||||||
|
<p className="text-text-secondary text-base md:text-base leading-relaxed">
|
||||||
|
{t(`items.${idx}.description`)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from './utils';
|
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 (
|
return (
|
||||||
<div className={cn('premium-card overflow-hidden', className)} {...props}>
|
<Tag className={cn('premium-card overflow-hidden', className)} {...props}>
|
||||||
{children}
|
{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
|
featuredImage: /uploads/2025/02/image_fx_-6.webp
|
||||||
locale: de
|
locale: de
|
||||||
category: Kabel Technologie
|
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!
|
# 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.
|
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.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ services:
|
|||||||
- klz.localhost
|
- klz.localhost
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
@@ -30,7 +32,7 @@ services:
|
|||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
||||||
|
|
||||||
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
||||||
- "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`) || 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}-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(`/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$`))"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "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.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
@@ -152,6 +154,40 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- default
|
- 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_ALLOWED_NETWORKS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
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:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function createConfig() {
|
|||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: env.UMAMI_WEBSITE_ID,
|
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),
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
80
lib/imgproxy.ts
Normal file
80
lib/imgproxy.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a string to Base64 (URL-safe)
|
||||||
|
*/
|
||||||
|
function encodeBase64(str: string): string {
|
||||||
|
if (typeof Buffer !== 'undefined') {
|
||||||
|
return Buffer.from(str)
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
} else {
|
||||||
|
// Fallback for browser environment if Buffer is not available
|
||||||
|
return window.btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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/ for now as we don't handle signatures yet
|
||||||
|
// Format: <base_url>/unsafe/<options>/<base64_url>
|
||||||
|
const suffix = extension ? `@${extension}` : '';
|
||||||
|
const encodedSrc = encodeBase64(absoluteSrc + suffix);
|
||||||
|
|
||||||
|
return `${baseUrl}/unsafe/${processingOptions}/${encodedSrc}`;
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
|
|
||||||
// Add a timeout to prevent hanging requests
|
// Add a timeout to prevent hanging requests
|
||||||
const controller = new AbortController();
|
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> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export class GotifyNotificationService implements NotificationService {
|
|||||||
const url = new URL('message', this.config.url);
|
const url = new URL('message', this.config.url);
|
||||||
url.searchParams.set('token', this.config.token);
|
url.searchParams.set('token', this.config.token);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -27,8 +30,11 @@ export class GotifyNotificationService implements NotificationService {
|
|||||||
message,
|
message,
|
||||||
priority,
|
priority,
|
||||||
}),
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error('Gotify notification failed:', {
|
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": {
|
"Navigation": {
|
||||||
"home": "Start",
|
"menu": "Menü",
|
||||||
|
"home": "KLZ Cables Startseite",
|
||||||
"team": "Team",
|
"team": "Team",
|
||||||
"products": "Produkte",
|
"products": "Produkte",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"contact": "Kontakt",
|
"contact": "Kontakt",
|
||||||
"toggleMenu": "Menü umschalten"
|
"toggleMenu": "Menü umschalten",
|
||||||
|
"skipToContent": "Zum Inhalt springen"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"legal": "Rechtliches",
|
"legal": "Rechtliches",
|
||||||
@@ -394,4 +396,4 @@
|
|||||||
"cta": "Zurück zur Sicherheit"
|
"cta": "Zurück zur Sicherheit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,12 +58,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"home": "Home",
|
"menu": "Menu",
|
||||||
|
"home": "KLZ Cables Home",
|
||||||
"team": "Team",
|
"team": "Team",
|
||||||
"products": "Products",
|
"products": "Products",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"toggleMenu": "Toggle Menu"
|
"toggleMenu": "Toggle Menu",
|
||||||
|
"skipToContent": "Skip to content"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"legal": "Legal",
|
"legal": "Legal",
|
||||||
@@ -394,4 +396,4 @@
|
|||||||
"cta": "Back to Safety"
|
"cta": "Back to Safety"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import createMiddleware from 'next-intl/middleware';
|
import createMiddleware from 'next-intl/middleware';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
// Create the internationalization middleware
|
// Create the internationalization middleware
|
||||||
const intlMiddleware = createMiddleware({
|
const intlMiddleware = createMiddleware({
|
||||||
@@ -20,9 +20,11 @@ export default function middleware(request: NextRequest) {
|
|||||||
pathname.startsWith('/errors') ||
|
pathname.startsWith('/errors') ||
|
||||||
pathname.startsWith('/health') ||
|
pathname.startsWith('/health') ||
|
||||||
pathname.includes('/api/og') ||
|
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
|
// Build header object for logging
|
||||||
@@ -93,6 +95,7 @@ export default function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf)$).*)',
|
'/((?!api|_next/static|_next/image|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)',
|
||||||
|
'/(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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -317,16 +317,10 @@ const nextConfig = {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
loader: 'custom',
|
||||||
{
|
loaderFile: './lib/imgproxy-loader.ts',
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'klz-cables.com',
|
|
||||||
port: '',
|
|
||||||
pathname: '/wp-content/uploads/**',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dangerouslyAllowSVG: true,
|
dangerouslyAllowSVG: true,
|
||||||
contentDispositionType: 'attachment',
|
contentDispositionType: "attachment",
|
||||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
@@ -341,11 +335,20 @@ const nextConfig = {
|
|||||||
|
|
||||||
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
source: '/cms/:path*',
|
source: '/cms/:path*',
|
||||||
destination: `${directusUrl}/:path*`,
|
destination: `${directusUrl}/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/_img/:path*',
|
||||||
|
destination: `${imgproxyUrl}/:path*`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@react-email/components": "^1.0.7",
|
"@react-email/components": "^1.0.7",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@sentry/nextjs": "^10.38.0",
|
"@sentry/nextjs": "^10.38.0",
|
||||||
|
"@types/recharts": "^2.0.1",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-email": "^5.2.5",
|
"react-email": "^5.2.5",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"require-in-the-middle": "^8.0.1",
|
"require-in-the-middle": "^8.0.1",
|
||||||
"resend": "^3.5.0",
|
"resend": "^3.5.0",
|
||||||
"schema-dts": "^1.1.5",
|
"schema-dts": "^1.1.5",
|
||||||
@@ -63,15 +65,19 @@
|
|||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.6.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"pa11y-ci": "^4.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"puppeteer": "^24.37.3",
|
||||||
"remotion": "^4.0.421",
|
"remotion": "^4.0.421",
|
||||||
"sass": "^1.97.1",
|
"sass": "^1.97.1",
|
||||||
|
"start-server-and-test": "^2.1.3",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
@@ -89,6 +95,8 @@
|
|||||||
"test:og": "vitest run tests/og-image.test.ts",
|
"test:og": "vitest run tests/og-image.test.ts",
|
||||||
"check:og": "tsx scripts/check-og-images.ts",
|
"check:og": "tsx scripts/check-og-images.ts",
|
||||||
"check:mdx": "node scripts/validate-mdx.mjs",
|
"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: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: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",
|
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||||
@@ -108,6 +116,7 @@
|
|||||||
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
|
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
|
||||||
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
|
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
|
||||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
"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')))\"",
|
"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:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
|
||||||
"remotion:preview": "remotion preview remotion/index.ts",
|
"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"
|
||||||
@@ -86,7 +86,7 @@ async function main() {
|
|||||||
|
|
||||||
// Using a more robust way to execute and capture output
|
// Using a more robust way to execute and capture output
|
||||||
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports
|
// 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...`);
|
console.log(`💻 Executing LHCI...`);
|
||||||
|
|
||||||
|
|||||||
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