Compare commits
5 Commits
v2.2.14
...
3a61d01384
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a61d01384 | |||
| 17ebde407e | |||
| 56cd1fb1ba | |||
| 437dd35c9c | |||
| 0cb96dfbac |
@@ -576,11 +576,6 @@ jobs:
|
||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
|
||||
@@ -5,232 +5,13 @@ on:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TARGET_URL: 'https://testing.klz-cables.com'
|
||||
PROJECT_NAME: 'klz-2026'
|
||||
|
||||
jobs:
|
||||
# ────────────────────────────────────────────────────
|
||||
# 1. Static Checks (HTML, Assets, HTTP)
|
||||
# ────────────────────────────────────────────────────
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome
|
||||
- name: 🌐 HTML Validation
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:html
|
||||
- name: 🖼️ Broken Assets
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
ASSET_CHECK_LIMIT: 10
|
||||
run: pnpm run check:assets
|
||||
- name: 🔒 HTTP Headers
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:http
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 2. Accessibility (WCAG)
|
||||
# ────────────────────────────────────────────────────
|
||||
a11y:
|
||||
name: ♿ Accessibility
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome
|
||||
- name: ♿ WCAG Scan
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:wcag
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 3. Performance (Lighthouse)
|
||||
# ────────────────────────────────────────────────────
|
||||
lighthouse:
|
||||
name: 🎭 Lighthouse
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome
|
||||
- name: 🎭 Desktop
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||
- name: 📱 Mobile
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 4. Link Check & Dependency Audit
|
||||
# ────────────────────────────────────────────────────
|
||||
links:
|
||||
name: 🔗 Links & Deps
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Depcheck
|
||||
continue-on-error: true
|
||||
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||
- name: 🔗 Lychee Link Check
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
with:
|
||||
args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" .
|
||||
fail: true
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 5. Notification
|
||||
# ────────────────────────────────────────────────────
|
||||
notify:
|
||||
name: 🔔 Notify
|
||||
needs: [static, a11y, lighthouse, links]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
STATIC="${{ needs.static.result }}"
|
||||
A11Y="${{ needs.a11y.result }}"
|
||||
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||
LINKS="${{ needs.links.result }}"
|
||||
|
||||
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="🚨"
|
||||
STATUS="Failed"
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS="Passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||
${{ env.TARGET_URL }}"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
call-qa-workflow:
|
||||
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main
|
||||
with:
|
||||
TARGET_URL: 'https://testing.klz-cables.com'
|
||||
PROJECT_NAME: 'klz-2026'
|
||||
secrets:
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
"valid-id": "off",
|
||||
"element-required-attributes": "off",
|
||||
"attribute-empty-style": "off",
|
||||
"element-permitted-content": "off",
|
||||
"element-required-content": "off",
|
||||
"element-permitted-parent": "off",
|
||||
"no-implicit-close": "off",
|
||||
"close-order": "off"
|
||||
"element-permitted-content": "off"
|
||||
}
|
||||
}
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -1,9 +1,5 @@
|
||||
# Stage 1: Builder
|
||||
FROM node:20-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.8.20 AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Arguments for build-time configuration
|
||||
@@ -56,17 +52,12 @@ ENV UV_THREADPOOL_SIZE=3
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:v1.8.20 AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs && \
|
||||
chown -R nextjs:nodejs /app
|
||||
|
||||
USER root
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
USER nextjs
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notFound, redirect, permanentRedirect } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
@@ -62,15 +62,6 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Handle explicit CMS redirects (e.g. /en/terms -> /de/terms)
|
||||
if (pageData.redirectUrl) {
|
||||
if (pageData.redirectPermanent) {
|
||||
permanentRedirect(pageData.redirectUrl);
|
||||
} else {
|
||||
redirect(pageData.redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect if accessed via a different locale's slug
|
||||
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getAdjacentPosts,
|
||||
getReadingTime,
|
||||
extractLexicalHeadings,
|
||||
getPostSlugs,
|
||||
} from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
@@ -34,21 +33,12 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
|
||||
if (!post) return {};
|
||||
|
||||
const slugs = await getPostSlugs(slug, locale);
|
||||
const deSlug = slugs?.de || post.slug;
|
||||
const enSlug = slugs?.en || post.slug;
|
||||
|
||||
const description = post.frontmatter.excerpt || '';
|
||||
return {
|
||||
title: post.frontmatter.title,
|
||||
description: description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/blog/${deSlug}`,
|
||||
en: `${SITE_URL}/en/blog/${enSlug}`,
|
||||
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||
@@ -108,7 +98,6 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
priority
|
||||
quality={100}
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
style={{
|
||||
|
||||
@@ -8,7 +8,6 @@ import { SITE_URL } from '@/lib/schema';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { Suspense } from 'react';
|
||||
import ContactMap from '@/components/ContactMap';
|
||||
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||
|
||||
interface ContactPageProps {
|
||||
params: Promise<{
|
||||
@@ -205,10 +204,12 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||
{t('info.email')}
|
||||
</h4>
|
||||
<ObfuscatedEmail
|
||||
email="info@klz-cables.com"
|
||||
<a
|
||||
href="mailto:info@klz-cables.com"
|
||||
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||
/>
|
||||
>
|
||||
info@klz-cables.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</address>
|
||||
|
||||
@@ -35,6 +35,13 @@ export async function generateMetadata(props: {
|
||||
},
|
||||
metadataBase: new URL(baseUrl),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/${locale}`,
|
||||
languages: {
|
||||
de: `${baseUrl}/de`,
|
||||
en: `${baseUrl}/en`,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
@@ -84,6 +91,7 @@ export default async function Layout(props: {
|
||||
'Home',
|
||||
'Error',
|
||||
'StandardPage',
|
||||
'Brochure',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
@@ -125,7 +133,11 @@ export default async function Layout(props: {
|
||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||
|
||||
return (
|
||||
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
|
||||
<html
|
||||
lang={safeLocale}
|
||||
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
|
||||
data-scroll-behavior="smooth"
|
||||
>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
|
||||
@@ -2,11 +2,12 @@ import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
@@ -278,6 +279,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||
const excelPath = getExcelDatasheetPath(productSlug, locale);
|
||||
const isFallback = (product.frontmatter as any).isFallback;
|
||||
const categorySlug = slug[0];
|
||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||
@@ -343,6 +345,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
productName={product.frontmatter.title}
|
||||
productImage={product.frontmatter.images?.[0]}
|
||||
datasheetPath={datasheetPath}
|
||||
excelPath={excelPath}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -496,7 +499,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||
<div className="flex flex-col gap-4 max-w-2xl">
|
||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
@@ -94,7 +95,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
{/* Hero Section */}
|
||||
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
|
||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge
|
||||
@@ -105,7 +106,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
</Badge>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</Heading>
|
||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||
@@ -214,7 +223,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||
<div className="max-w-2xl text-center lg:text-left">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||
{t('cta.title')}
|
||||
</h2>
|
||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||
|
||||
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||
{t('michael.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
||||
<span className="text-white">{t('michael.name')}</span>
|
||||
</Heading>
|
||||
<div className="relative mb-6 md:mb-12">
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||
{t('michael.quote')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,7 +156,6 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
alt={t('michael.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||
@@ -226,7 +225,6 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
alt={t('klaus.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||
@@ -237,12 +235,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||
{t('klaus.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||
{t('klaus.name')}
|
||||
</Heading>
|
||||
<div className="relative mb-6 md:mb-12">
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||
{t('klaus.quote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
78
app/actions/brochure.ts
Normal file
78
app/actions/brochure.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
'use server';
|
||||
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
message: `Brochure download request (${locale})`,
|
||||
type: 'brochure_download' as any,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||
} catch (error) {
|
||||
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||
}
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
});
|
||||
|
||||
// Return the brochure URL
|
||||
const brochureUrl = `/brochure/klz-product-catalog-${locale}.pdf`;
|
||||
|
||||
return { success: true, brochureUrl };
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { renderToStream } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { PDFPage } from '@/lib/pdf-page';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Get Payload App
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Fetch the page
|
||||
const pages = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
_status: { equals: 'published' },
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (pages.totalDocs === 0) {
|
||||
return new NextResponse('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const page = pages.docs[0];
|
||||
|
||||
// Determine locale from searchParams or default to 'de'
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||
|
||||
// Render the React-PDF document into a stream
|
||||
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||
|
||||
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
stream.on('end', () => controller.close());
|
||||
stream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
(stream as any).destroy?.();
|
||||
},
|
||||
});
|
||||
|
||||
const filename = `${slug}.pdf`;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
// Cache control if needed, skip for now.
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
253
components/BrochureCTA.tsx
Normal file
253
components/BrochureCTA.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BrochureCTA — Shows a button that opens a modal asking for an email address.
|
||||
* The full-catalog PDF is ONLY revealed after email submission.
|
||||
* No direct download link is exposed anywhere.
|
||||
*/
|
||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
|
||||
const [url, setUrl] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function openModal() { setOpen(true); }
|
||||
function closeModal() {
|
||||
setOpen(false);
|
||||
setPhase('form');
|
||||
setUrl('');
|
||||
setErr('');
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
setPhase('loading');
|
||||
|
||||
const fd = new FormData(formRef.current);
|
||||
fd.set('locale', locale);
|
||||
|
||||
try {
|
||||
const res = await requestBrochureAction(fd);
|
||||
if (res.success && res.brochureUrl) {
|
||||
setUrl(res.brochureUrl);
|
||||
setPhase('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setErr(res.error || 'Error');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error');
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trigger Button ─────────────────────────────────────────────────
|
||||
const trigger = (
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">PDF Katalog</span>
|
||||
<span className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────────
|
||||
const modal = mounted && open ? createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={closeModal}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: '26rem', borderRadius: '1.5rem', background: '#000d26', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 40px 80px rgba(0,0,0,0.6)', overflow: 'hidden' }}>
|
||||
|
||||
{/* Green top bar */}
|
||||
<div style={{ height: '3px', background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)' }} />
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2rem', height: '2rem', borderRadius: '50%', background: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.4)', border: 'none', cursor: 'pointer' }}
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style={{ padding: '2rem' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.75rem', height: '2.75rem', borderRadius: '0.75rem', background: 'rgba(130,237,32,0.1)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
|
||||
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', color: 'rgba(255,255,255,0.5)', lineHeight: 1.6 }}>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{phase === 'success' ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', borderRadius: '1rem', background: 'rgba(130,237,32,0.08)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.25rem', height: '2.25rem', borderRadius: '0.625rem', background: 'rgba(130,237,32,0.15)', flexShrink: 0 }}>
|
||||
<svg width="18" height="18" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 700, color: '#82ed20' }}>{t('successTitle')}</p>
|
||||
<p style={{ margin: '0.125rem 0 0', fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>{t('successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%', padding: '1rem', borderRadius: '1rem', background: '#82ed20', color: '#000d26', fontWeight: 900, fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.1em', textDecoration: 'none' }}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<label style={{ display: 'block', fontSize: '0.625rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)', marginBottom: '0.5rem' }}>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
style={{ width: '100%', padding: '0.875rem 1rem', borderRadius: '0.75rem', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.875rem', fontWeight: 500, outline: 'none', boxSizing: 'border-box', marginBottom: '0.75rem' }}
|
||||
/>
|
||||
|
||||
{phase === 'error' && err && (
|
||||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.75rem', color: '#f87171', fontWeight: 500 }}>{err}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
|
||||
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
border: 'none',
|
||||
cursor: phase === 'loading' ? 'wait' : 'pointer',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
211
components/BrochureModal.tsx
Normal file
211
components/BrochureModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface BrochureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [brochureUrl, setBrochureUrl] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Mount guard for SSR/portal
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Close on escape + lock scroll
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData(formRef.current);
|
||||
formData.set('locale', locale);
|
||||
|
||||
const result = await requestBrochureAction(formData);
|
||||
|
||||
if (result.success && result.brochureUrl) {
|
||||
setState('success');
|
||||
setBrochureUrl(result.brochureUrl);
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setState('error');
|
||||
setErrorMsg(result.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMsg('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState('idle');
|
||||
setBrochureUrl(null);
|
||||
setErrorMsg('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!mounted || !isOpen) return null;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden">
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg className="h-6 w-6 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state === 'success' && brochureUrl ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg className="h-5 w-5 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">{t('successTitle')}</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">{t('successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={brochureUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26] font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||
PDF Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* 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 flex-shrink-0 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"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
94
components/ExcelDownload.tsx
Normal file
94
components/ExcelDownload.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface ExcelDownloadProps {
|
||||
excelPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
<a
|
||||
href={excelPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: excelPath.split('/').pop(),
|
||||
file_path: excelPath,
|
||||
file_type: 'excel',
|
||||
location: 'product_page',
|
||||
})
|
||||
}
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{/* Spreadsheet/Table Icon */}
|
||||
<svg
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
|
||||
Excel Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
|
||||
{t('downloadExcel')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadExcelDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import BrochureCTA from './BrochureCTA';
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations('Footer');
|
||||
@@ -187,6 +188,9 @@ export default function Footer() {
|
||||
{navT('contact')}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="pt-2">
|
||||
<BrochureCTA compact className="opacity-80 hover:opacity-100 transition-opacity" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ObfuscatedEmailProps {
|
||||
email: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that helps protect email addresses from simple spambots.
|
||||
* It uses client-side mounting to render the actual email address,
|
||||
* making it harder for static crawlers to harvest.
|
||||
*/
|
||||
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
// Show a placeholder or obscured version during SSR
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Once mounted on the client, render the real mailto link
|
||||
return (
|
||||
<a href={`mailto:${email}`} className={className}>
|
||||
{children || email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ObfuscatedPhoneProps {
|
||||
phone: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that helps protect phone numbers from simple spambots.
|
||||
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
|
||||
*/
|
||||
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Format phone number for tel: link (remove spaces, etc.)
|
||||
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
|
||||
|
||||
if (!mounted) {
|
||||
// Show a placeholder or obscured version during SSR
|
||||
// e.g. +49 881 925 [at] 37298
|
||||
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{children || obscured}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={telLink} className={className}>
|
||||
{children || phone}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<a
|
||||
href={href}
|
||||
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||
style === 'primary'
|
||||
? 'bg-primary text-white hover:bg-primary-dark'
|
||||
: style === 'secondary'
|
||||
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
|
||||
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
||||
import Image from 'next/image';
|
||||
import { Suspense, Fragment } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
// Import all custom React components that were previously mapped via Markdown
|
||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||
@@ -24,8 +24,6 @@ import Reveal from '@/components/Reveal';
|
||||
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
import { useLocale } from 'next-intl';
|
||||
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||
import ObfuscatedPhone from '@/components/ObfuscatedPhone';
|
||||
|
||||
import HomeHero from '@/components/home/Hero';
|
||||
import ProductCategories from '@/components/home/ProductCategories';
|
||||
@@ -37,97 +35,10 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
|
||||
import GallerySection from '@/components/home/GallerySection';
|
||||
import VideoSection from '@/components/home/VideoSection';
|
||||
import CTA from '@/components/home/CTA';
|
||||
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
|
||||
|
||||
/**
|
||||
* Splits a text string on \n and intersperses <br /> elements.
|
||||
* This is needed because Lexical stores newlines as literal \n characters inside
|
||||
* text nodes (e.g. dash-lists typed in the editor), but HTML collapses whitespace.
|
||||
*/
|
||||
function textWithLineBreaks(text: string, key: string) {
|
||||
const parts = text.split('\n');
|
||||
if (parts.length === 1) return text;
|
||||
return parts.map((part, i) => (
|
||||
<Fragment key={`${key}-${i}`}>
|
||||
{part}
|
||||
{i < parts.length - 1 && <br />}
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
const jsxConverters: JSXConverters = {
|
||||
...defaultJSXConverters,
|
||||
// Handle Lexical linebreak nodes (explicit shift+enter)
|
||||
linebreak: () => <br />,
|
||||
// Custom text converter: preserve \n inside text nodes as <br /> and obfuscate emails
|
||||
text: ({ node }: any) => {
|
||||
let content: React.ReactNode = node.text || '';
|
||||
// Split newlines first
|
||||
if (typeof content === 'string' && content.includes('\n')) {
|
||||
content = textWithLineBreaks(content, `t-${(node.text || '').slice(0, 8)}`);
|
||||
}
|
||||
|
||||
// Obfuscate emails in text content
|
||||
if (typeof content === 'string' && content.includes('@')) {
|
||||
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
|
||||
const parts = content.split(emailRegex);
|
||||
content = parts.map((part, i) => {
|
||||
if (part.match(emailRegex)) {
|
||||
return <ObfuscatedEmail key={`e-${i}`} email={part} />;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
// Obfuscate phone numbers in text content (simple pattern for +XX XXX ...)
|
||||
if (typeof content === 'string' && content.match(/\+\d+/)) {
|
||||
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||
const parts = content.split(phoneRegex);
|
||||
content = parts.map((part, i) => {
|
||||
if (part.match(phoneRegex)) {
|
||||
return <ObfuscatedPhone key={`p-${i}`} phone={part} />;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle array content (from previous mappings)
|
||||
if (Array.isArray(content)) {
|
||||
content = content.map((item, idx) => {
|
||||
if (typeof item === 'string') {
|
||||
// Re-apply phone regex to strings in array
|
||||
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||
if (item.match(phoneRegex)) {
|
||||
const parts = item.split(phoneRegex);
|
||||
return parts.map((part, i) => {
|
||||
if (part.match(phoneRegex)) {
|
||||
return <ObfuscatedPhone key={`p-${idx}-${i}`} phone={part} />;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply Lexical formatting flags
|
||||
if (node.format) {
|
||||
if (node.format & 1) content = <strong>{content}</strong>;
|
||||
if (node.format & 2) content = <em>{content}</em>;
|
||||
if (node.format & 8) content = <u>{content}</u>;
|
||||
if (node.format & 4) content = <s>{content}</s>;
|
||||
if (node.format & 16)
|
||||
content = (
|
||||
<code className="px-1.5 py-0.5 bg-neutral-100 rounded text-sm font-mono text-primary">
|
||||
{content}
|
||||
</code>
|
||||
);
|
||||
if (node.format & 32) content = <sub>{content}</sub>;
|
||||
if (node.format & 64) content = <sup>{content}</sup>;
|
||||
}
|
||||
return <>{content}</>;
|
||||
},
|
||||
// Let the default converters handle text nodes to preserve valid formatting
|
||||
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
||||
paragraph: ({ node, nodesToJSX }: any) => {
|
||||
return (
|
||||
@@ -162,7 +73,7 @@ const jsxConverters: JSXConverters = {
|
||||
return (
|
||||
<h2
|
||||
id={id}
|
||||
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||
className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
@@ -171,7 +82,7 @@ const jsxConverters: JSXConverters = {
|
||||
return (
|
||||
<h3
|
||||
id={id}
|
||||
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
||||
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
@@ -180,7 +91,7 @@ const jsxConverters: JSXConverters = {
|
||||
return (
|
||||
<h4
|
||||
id={id}
|
||||
className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
|
||||
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
@@ -257,17 +168,6 @@ const jsxConverters: JSXConverters = {
|
||||
// Handling Payload CMS link nodes
|
||||
const href = node?.fields?.url || node?.url || '#';
|
||||
const newTab = node?.fields?.newTab || node?.newTab;
|
||||
|
||||
if (href.startsWith('mailto:')) {
|
||||
const email = href.replace('mailto:', '');
|
||||
return (
|
||||
<ObfuscatedEmail
|
||||
email={email}
|
||||
className="text-primary no-underline hover:underline font-medium transition-colors"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@@ -430,12 +330,6 @@ const jsxConverters: JSXConverters = {
|
||||
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
||||
</ProductTabs>
|
||||
),
|
||||
pdfDownload: ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
'block-pdfDownload': ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
// ─── New Page Blocks ───────────────────────────────────────────
|
||||
heroSection: ({ node }: any) => {
|
||||
const f = node.fields;
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
|
||||
@@ -11,6 +12,7 @@ interface ProductSidebarProps {
|
||||
productName: string;
|
||||
productImage?: string;
|
||||
datasheetPath?: string | null;
|
||||
excelPath?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -18,6 +20,7 @@ export default function ProductSidebar({
|
||||
productName,
|
||||
productImage,
|
||||
datasheetPath,
|
||||
excelPath,
|
||||
className,
|
||||
}: ProductSidebarProps) {
|
||||
const t = useTranslations('Products');
|
||||
@@ -70,6 +73,9 @@ export default function ProductSidebar({
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||
|
||||
{/* Excel Download – right below datasheet */}
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface KeyValueItem {
|
||||
label: string;
|
||||
@@ -45,22 +46,40 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
General Data
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||
{technicalItems.map((item, idx) => (
|
||||
<div key={idx} className="flex flex-col group">
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||
{item.label}
|
||||
</dt>
|
||||
<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>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||
{technicalItems.map((item, idx) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<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>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
@@ -77,7 +96,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
{table.voltageLabel !== 'Voltage unknown' &&
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
: 'Technical Specifications'}
|
||||
</h3>
|
||||
@@ -102,9 +121,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||
<div
|
||||
id={`voltage-table-${idx}`}
|
||||
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
|
||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
>
|
||||
<table className="min-w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface TechnicalGridItem {
|
||||
label: string;
|
||||
@@ -18,25 +18,41 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
||||
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
|
||||
<span className="relative inline-block">
|
||||
{title}
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ export default function Experience({ data }: { data?: any }) {
|
||||
fill
|
||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
quality={100}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import dynamic from 'next/dynamic';
|
||||
@@ -19,19 +20,37 @@ export default function Hero({ data }: { data?: any }) {
|
||||
<div>
|
||||
<Heading
|
||||
level={1}
|
||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
|
||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||
>
|
||||
{data?.title ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.title
|
||||
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||
.replace(/<\/green>/g, '</span>'),
|
||||
.replace(
|
||||
/<green>/g,
|
||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
||||
)
|
||||
.replace(
|
||||
/<\/green>/g,
|
||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t.rich('title', {
|
||||
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic inline-block">
|
||||
{chunks}
|
||||
</span>
|
||||
<div
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
||||
style={{ animationDelay: '500ms' }}
|
||||
>
|
||||
<Scribble variant="circle" />
|
||||
</div>
|
||||
</span>
|
||||
),
|
||||
})
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
@@ -17,7 +17,6 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
||||
fill
|
||||
className="object-cover scale-105 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
quality={100}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function VideoSection({ data }: { data?: any }) {
|
||||
@@ -40,16 +41,18 @@ export default function VideoSection({ data }: { data?: any }) {
|
||||
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||
{data?.title ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.title
|
||||
.replace(/<future>/g, '<span class="italic text-accent">')
|
||||
.replace(/<\/future>/g, '</span>'),
|
||||
}}
|
||||
/>
|
||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} />
|
||||
) : (
|
||||
t.rich('title', {
|
||||
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
|
||||
future: (chunks) => (
|
||||
<span className="relative inline-block mx-2">
|
||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-4 -bottom-2 left-0 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
})
|
||||
)}
|
||||
</h2>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"numberOfRuns": 1,
|
||||
"numberOfRuns": 3,
|
||||
"settings": {
|
||||
"preset": "desktop",
|
||||
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||
"onlyCategories": [
|
||||
"performance",
|
||||
"accessibility",
|
||||
"best-practices",
|
||||
"seo"
|
||||
],
|
||||
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||
}
|
||||
},
|
||||
@@ -13,7 +18,7 @@
|
||||
"categories:performance": [
|
||||
"error",
|
||||
{
|
||||
"minScore": 0.7
|
||||
"minScore": 0.9
|
||||
}
|
||||
],
|
||||
"categories:accessibility": [
|
||||
@@ -49,4 +54,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
data/excel/high-voltage.xlsx
Normal file
BIN
data/excel/high-voltage.xlsx
Normal file
Binary file not shown.
BIN
data/excel/low-voltage-KM.xlsx
Normal file
BIN
data/excel/low-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/solar-cables.xlsx
Normal file
BIN
data/excel/solar-cables.xlsx
Normal file
Binary file not shown.
58
lib/blog.ts
58
lib/blog.ts
@@ -116,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
||||
category: doc.category || '',
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
focalX:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
@@ -136,60 +136,6 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPostSlugs(slug: string, locale: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// First, find the post in the current locale to get its ID
|
||||
let { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
locale: locale as any,
|
||||
draft: config.showDrafts,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (!docs || docs.length === 0) {
|
||||
// Fallback: search across all locales
|
||||
const { docs: crossLocaleDocs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
locale: 'all',
|
||||
draft: config.showDrafts,
|
||||
limit: 1,
|
||||
});
|
||||
docs = crossLocaleDocs;
|
||||
}
|
||||
|
||||
if (!docs || docs.length === 0) return {};
|
||||
|
||||
const postId = docs[0].id;
|
||||
|
||||
// Fetch the post with locale 'all' to get all localized fields
|
||||
const { docs: allLocalesDocs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
id: { equals: postId },
|
||||
},
|
||||
locale: 'all',
|
||||
draft: config.showDrafts,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (!allLocalesDocs || allLocalesDocs.length === 0) return {};
|
||||
return (allLocalesDocs[0].slug as unknown as Record<string, string>) || {};
|
||||
} catch (error) {
|
||||
console.error(`[Payload] getPostSlugs failed for ${slug}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
@@ -216,7 +162,7 @@ export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||
category: doc.category || '',
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
focalX:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from 'path';
|
||||
*/
|
||||
export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||
|
||||
|
||||
if (!fs.existsSync(datasheetsDir)) {
|
||||
return null;
|
||||
}
|
||||
@@ -16,16 +16,21 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
|
||||
// Subdirectories to search in
|
||||
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
// List of patterns to try for the current locale
|
||||
// Also try with -mv and -hv suffixes since some product slugs omit the voltage class
|
||||
const patterns = [
|
||||
`${slug}-${locale}.pdf`,
|
||||
`${slug}-2-${locale}.pdf`,
|
||||
`${slug}-3-${locale}.pdf`,
|
||||
`${slug}-mv-${locale}.pdf`,
|
||||
`${slug}-hv-${locale}.pdf`,
|
||||
`${normalizedSlug}-${locale}.pdf`,
|
||||
`${normalizedSlug}-2-${locale}.pdf`,
|
||||
`${normalizedSlug}-3-${locale}.pdf`,
|
||||
`${normalizedSlug}-mv-${locale}.pdf`,
|
||||
`${normalizedSlug}-hv-${locale}.pdf`,
|
||||
];
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
@@ -44,9 +49,70 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
`${slug}-en.pdf`,
|
||||
`${slug}-2-en.pdf`,
|
||||
`${slug}-3-en.pdf`,
|
||||
`${slug}-mv-en.pdf`,
|
||||
`${slug}-hv-en.pdf`,
|
||||
`${normalizedSlug}-en.pdf`,
|
||||
`${normalizedSlug}-2-en.pdf`,
|
||||
`${normalizedSlug}-3-en.pdf`,
|
||||
`${normalizedSlug}-mv-en.pdf`,
|
||||
`${normalizedSlug}-hv-en.pdf`,
|
||||
];
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the datasheet Excel path for a given product slug and locale.
|
||||
* Checks public/datasheets for matching .xlsx files.
|
||||
*/
|
||||
export function getExcelDatasheetPath(slug: string, locale: string): string | null {
|
||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||
|
||||
if (!fs.existsSync(datasheetsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
const patterns = [
|
||||
`${slug}-${locale}.xlsx`,
|
||||
`${slug}-2-${locale}.xlsx`,
|
||||
`${slug}-3-${locale}.xlsx`,
|
||||
`${normalizedSlug}-${locale}.xlsx`,
|
||||
`${normalizedSlug}-2-${locale}.xlsx`,
|
||||
`${normalizedSlug}-3-${locale}.xlsx`,
|
||||
];
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of patterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
const enPatterns = [
|
||||
`${slug}-en.xlsx`,
|
||||
`${slug}-2-en.xlsx`,
|
||||
`${slug}-3-en.xlsx`,
|
||||
`${normalizedSlug}-en.xlsx`,
|
||||
`${normalizedSlug}-2-en.xlsx`,
|
||||
`${normalizedSlug}-3-en.xlsx`,
|
||||
];
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
|
||||
@@ -11,21 +11,10 @@ export async function getOgFonts() {
|
||||
|
||||
try {
|
||||
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
||||
const boldFontBuffer = readFileSync(boldFontPath);
|
||||
const regularFontBuffer = readFileSync(regularFontPath);
|
||||
|
||||
// Satori (Vercel OG) strictly requires an ArrayBuffer, not a Node Buffer view.
|
||||
const boldFont = boldFontBuffer.buffer.slice(
|
||||
boldFontBuffer.byteOffset,
|
||||
boldFontBuffer.byteOffset + boldFontBuffer.byteLength,
|
||||
);
|
||||
const regularFont = regularFontBuffer.buffer.slice(
|
||||
regularFontBuffer.byteOffset,
|
||||
regularFontBuffer.byteOffset + regularFontBuffer.byteLength,
|
||||
);
|
||||
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
console.log(
|
||||
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
|
||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
||||
);
|
||||
|
||||
return [
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface PageFrontmatter {
|
||||
|
||||
export interface PageData {
|
||||
slug: string;
|
||||
redirectUrl?: string;
|
||||
redirectPermanent?: boolean;
|
||||
frontmatter: PageFrontmatter;
|
||||
content: any; // Lexical AST Document
|
||||
}
|
||||
@@ -98,8 +96,6 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageD
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
redirectUrl: doc.redirectUrl,
|
||||
redirectPermanent: doc.redirectPermanent ?? true,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
excerpt: doc.excerpt || '',
|
||||
|
||||
633
lib/pdf-brochure.tsx
Normal file
633
lib/pdf-brochure.tsx
Normal file
@@ -0,0 +1,633 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
// ─── Brand Tokens ───────────────────────────────────────────────────────────
|
||||
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
green: '#4da612',
|
||||
greenLight: '#e8f5d8',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const PAGE = { w: 595.28, h: 841.89 }; // A4 in points
|
||||
const MARGIN = 56;
|
||||
const CONTENT_W = PAGE.w - MARGIN * 2;
|
||||
const HEADER_H = 52;
|
||||
const FOOTER_H = 48;
|
||||
const BODY_TOP = HEADER_H + 40;
|
||||
const BODY_BOTTOM = FOOTER_H + 24;
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BrochureProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
shortDescriptionHtml: string;
|
||||
descriptionHtml: string;
|
||||
applicationHtml?: string;
|
||||
images: string[];
|
||||
featuredImage: string | null;
|
||||
sku: string;
|
||||
slug: string;
|
||||
categories: Array<{ name: string }>;
|
||||
attributes: Array<{ name: string; options: string[] }>;
|
||||
qrWebsite?: string | Buffer;
|
||||
qrDatasheet?: string | Buffer;
|
||||
}
|
||||
|
||||
export interface BrochureProps {
|
||||
products: BrochureProduct[];
|
||||
locale: 'en' | 'de';
|
||||
companyInfo: {
|
||||
tagline: string;
|
||||
values: Array<{ title: string; description: string }>;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
};
|
||||
logoBlack?: string | Buffer;
|
||||
logoWhite?: string | Buffer;
|
||||
introContent?: { title: string; excerpt: string; heroImage?: string | Buffer };
|
||||
marketingSections?: Array<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description?: string;
|
||||
items?: Array<{ title: string; description: string }>;
|
||||
highlights?: Array<{ value: string; label: string }>;
|
||||
pullQuote?: string;
|
||||
}>;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const strip = (html: string): string => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const imgValid = (src?: string | Buffer): boolean => {
|
||||
if (!src) return false;
|
||||
if (Buffer.isBuffer(src)) return src.length > 0;
|
||||
return true;
|
||||
};
|
||||
|
||||
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
|
||||
catalog: 'Produktkatalog',
|
||||
subtitle: 'Hochwertige Stromkabel\nMittelspannungslösungen\nSolarkabel',
|
||||
about: 'Über uns', toc: 'Produktübersicht', overview: 'Produktübersicht',
|
||||
application: 'Anwendung', specs: 'Technische Daten', contact: 'Kontakt',
|
||||
qrWeb: 'Web', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'S.',
|
||||
property: 'Eigenschaft', value: 'Wert',
|
||||
} : {
|
||||
catalog: 'Product Catalog',
|
||||
subtitle: 'High-Quality Power Cables\nMedium Voltage Solutions\nSolar Cables',
|
||||
about: 'About Us', toc: 'Product Overview', overview: 'Product Overview',
|
||||
application: 'Application', specs: 'Technical Data', contact: 'Contact',
|
||||
qrWeb: 'Web', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'p.',
|
||||
property: 'Property', value: 'Value',
|
||||
};
|
||||
|
||||
// ─── Rich Text ──────────────────────────────────────────────────────────────
|
||||
|
||||
const RichText: React.FC<{ children: string; style?: any; gap?: number; color?: string }> = ({ children, style = {}, gap = 8, color }) => {
|
||||
const paragraphs = children.split('\n\n').filter(p => p.trim());
|
||||
return (
|
||||
<View style={{ gap }}>
|
||||
{paragraphs.map((para, pIdx) => {
|
||||
const parts: Array<{ text: string; bold?: boolean; italic?: boolean }> = [];
|
||||
let rem = para;
|
||||
while (rem.length > 0) {
|
||||
const bm = rem.match(/\*\*(.+?)\*\*/);
|
||||
const im = rem.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
|
||||
const first = [bm, im].filter(Boolean).sort((a, b) => (a!.index || 0) - (b!.index || 0))[0];
|
||||
if (!first || first.index === undefined) { parts.push({ text: rem }); break; }
|
||||
if (first.index > 0) parts.push({ text: rem.substring(0, first.index) });
|
||||
parts.push({ text: first[1], bold: first[0].startsWith('**'), italic: !first[0].startsWith('**') });
|
||||
rem = rem.substring(first.index + first[0].length);
|
||||
}
|
||||
return (
|
||||
<Text key={pIdx} style={style}>
|
||||
{parts.map((part, i) => (
|
||||
<Text key={i} style={{
|
||||
...(part.bold ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: color || C.navyDeep } : {}),
|
||||
...(part.italic ? { fontWeight: 700, fontFamily: 'Helvetica-Bold', color: C.green } : {}),
|
||||
}}>{part.text}</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Shared Components ──────────────────────────────────────────────────────
|
||||
|
||||
// Thin brand bar at the top of every page
|
||||
const Header: React.FC<{ logo?: string | Buffer; right?: string; dark?: boolean }> = ({ logo, right, dark }) => (
|
||||
<View style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_H,
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end',
|
||||
paddingHorizontal: MARGIN, paddingBottom: 12,
|
||||
}} fixed>
|
||||
{logo ? <Image src={logo} style={{ width: 56 }} /> : <Text style={{ fontSize: 14, fontWeight: 700, color: dark ? C.white : C.navy }}>KLZ</Text>}
|
||||
{right && <Text style={{ fontSize: 7, fontWeight: 700, color: dark ? C.gray400 : C.gray400, letterSpacing: 1.2, textTransform: 'uppercase' }}>{right}</Text>}
|
||||
</View>
|
||||
);
|
||||
|
||||
const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({ left, right, dark }) => (
|
||||
<View style={{
|
||||
position: 'absolute', bottom: 20, left: MARGIN, right: MARGIN,
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||
borderTopWidth: 0.5, borderTopColor: dark ? 'rgba(255,255,255,0.15)' : C.gray200, borderTopStyle: 'solid',
|
||||
paddingTop: 8,
|
||||
}} fixed>
|
||||
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray400, letterSpacing: 0.8, textTransform: 'uppercase' }}>{left}</Text>
|
||||
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray400, letterSpacing: 0.8, textTransform: 'uppercase' }}>{right}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Green accent bar
|
||||
const AccentBar = () => <View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 16 }} />;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PAGE 1: COVER
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const CoverPage: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
introContent?: BrochureProps['introContent'];
|
||||
logoWhite?: string | Buffer;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
}> = ({ locale, introContent, logoWhite, galleryImages }) => {
|
||||
const l = labels(locale);
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' });
|
||||
const bg = galleryImages?.[0] || introContent?.heroImage;
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica' }}>
|
||||
{/* Full-page background image with dark overlay */}
|
||||
{imgValid(bg) && (
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Image src={bg!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.82 }} />
|
||||
</View>
|
||||
)}
|
||||
{!imgValid(bg) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
|
||||
|
||||
{/* Vertical accent stripe */}
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, width: 5, height: '40%', backgroundColor: C.green }} />
|
||||
|
||||
{/* Logo top-left */}
|
||||
<View style={{ position: 'absolute', top: 56, left: MARGIN }}>
|
||||
{imgValid(logoWhite) ? <Image src={logoWhite!} style={{ width: 120 }} /> : <Text style={{ fontSize: 24, fontWeight: 700, color: C.white }}>KLZ</Text>}
|
||||
</View>
|
||||
|
||||
{/* Main title block — bottom third of page */}
|
||||
<View style={{ position: 'absolute', bottom: 160, left: MARGIN, right: MARGIN }}>
|
||||
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 24 }} />
|
||||
<Text style={{ fontSize: 56, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -1, lineHeight: 1 }}>
|
||||
{l.catalog}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: C.gray300, lineHeight: 1.8, marginTop: 20, maxWidth: 340 }}>
|
||||
{introContent?.excerpt || l.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<View style={{ position: 'absolute', bottom: 40, left: MARGIN, right: MARGIN, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 8, color: C.gray400, letterSpacing: 1, textTransform: 'uppercase' }}>{l.edition} {dateStr}</Text>
|
||||
<Text style={{ fontSize: 9, fontWeight: 700, color: C.green }}>www.klz-cables.com</Text>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PAGES 2–N: INFO PAGES (each marketing section = own page)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const InfoPage: React.FC<{
|
||||
section: NonNullable<BrochureProps['marketingSections']>[0];
|
||||
image?: string | Buffer;
|
||||
logoBlack?: string | Buffer;
|
||||
dark?: boolean;
|
||||
}> = ({ section, image, logoBlack, dark }) => {
|
||||
const bg = dark ? C.navyDeep : C.white;
|
||||
const textColor = dark ? C.gray300 : C.gray600;
|
||||
const titleColor = dark ? C.white : C.navyDeep;
|
||||
const boldColor = dark ? C.white : C.navyDeep;
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
<Header logo={logoBlack} right="KLZ Cables" dark={dark} />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" dark={dark} />
|
||||
|
||||
{/* Full-width image at top */}
|
||||
{imgValid(image) && (
|
||||
<View style={{ height: 200, marginBottom: 28, marginHorizontal: -MARGIN }}>
|
||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{dark && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.15 }} />}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Label + Title */}
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
|
||||
<Text style={{ fontSize: 28, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 8 }}>{section.title}</Text>
|
||||
<AccentBar />
|
||||
|
||||
{/* Description */}
|
||||
{section.description && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<RichText style={{ fontSize: 11, color: textColor, lineHeight: 1.7 }} gap={10} color={boldColor}>
|
||||
{section.description}
|
||||
</RichText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Highlights — horizontal stat cards */}
|
||||
{section.highlights && section.highlights.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
|
||||
{section.highlights.map((h, i) => (
|
||||
<View key={i} style={{
|
||||
flex: 1,
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
|
||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||
paddingVertical: 14, paddingHorizontal: 14,
|
||||
}}>
|
||||
<Text style={{ fontSize: 20, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
|
||||
<Text style={{ fontSize: 8, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Pull quote */}
|
||||
{section.pullQuote && (
|
||||
<View style={{
|
||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||
paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
|
||||
}}>
|
||||
<Text style={{ fontSize: 14, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
|
||||
„{section.pullQuote}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Items — 2-column grid with accent bars */}
|
||||
{section.items && section.items.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
||||
{section.items.map((item, i) => (
|
||||
<View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
|
||||
<View style={{ width: 20, height: 2, backgroundColor: C.green, marginBottom: 8 }} />
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
|
||||
<RichText style={{ fontSize: 9, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
|
||||
{item.description}
|
||||
</RichText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// About page (first info page, special layout with values grid)
|
||||
const AboutPage: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
companyInfo: BrochureProps['companyInfo'];
|
||||
logoBlack?: string | Buffer;
|
||||
image?: string | Buffer;
|
||||
}> = ({ locale, companyInfo, logoBlack, image }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
<Header logo={logoBlack} right="KLZ Cables" />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
{/* Full-width image at top */}
|
||||
{imgValid(image) && (
|
||||
<View style={{ height: 220, marginBottom: 28, marginHorizontal: -MARGIN }}>
|
||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
|
||||
<Text style={{ fontSize: 32, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>KLZ Cables</Text>
|
||||
<AccentBar />
|
||||
|
||||
<RichText style={{ fontSize: 13, color: C.gray900, lineHeight: 1.8 }} gap={12}>
|
||||
{companyInfo.tagline}
|
||||
</RichText>
|
||||
|
||||
{/* Values grid */}
|
||||
<View style={{ marginTop: 32 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 16 }}>{l.values}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
||||
{companyInfo.values.map((v, i) => (
|
||||
<View key={i} style={{ width: '46%', marginBottom: 8 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||
<View style={{ width: 28, height: 28, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 11, fontWeight: 700, color: C.navyDeep }}>{v.title}</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 9, color: C.gray600, lineHeight: 1.6, paddingLeft: 38 }}>{v.description}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TOC PAGE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const TocPage: React.FC<{
|
||||
products: BrochureProduct[];
|
||||
locale: 'en' | 'de';
|
||||
logoBlack?: string | Buffer;
|
||||
productStartPage: number;
|
||||
image?: string | Buffer;
|
||||
}> = ({ products, locale, logoBlack, productStartPage, image }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
const grouped = new Map<string, Array<{ product: BrochureProduct; pageNum: number }>>();
|
||||
let idx = 0;
|
||||
for (const p of products) {
|
||||
const cat = p.categories[0]?.name || 'Other';
|
||||
if (!grouped.has(cat)) grouped.set(cat, []);
|
||||
grouped.get(cat)!.push({ product: p, pageNum: productStartPage + idx });
|
||||
idx++;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
<Header logo={logoBlack} right={l.overview} />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
{/* Image strip */}
|
||||
{imgValid(image) && (
|
||||
<View style={{ height: 140, marginBottom: 28, marginHorizontal: -MARGIN }}>
|
||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.catalog}</Text>
|
||||
<Text style={{ fontSize: 28, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>{l.toc}</Text>
|
||||
<AccentBar />
|
||||
|
||||
{Array.from(grouped.entries()).map(([cat, items]) => (
|
||||
<View key={cat} style={{ marginBottom: 16 }}>
|
||||
<View style={{ borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 6 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2 }}>{cat}</Text>
|
||||
</View>
|
||||
{items.map((item, i) => (
|
||||
<View key={i} style={{
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||
paddingVertical: 5,
|
||||
borderBottomWidth: i < items.length - 1 ? 0.5 : 0,
|
||||
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||
}}>
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep }}>{item.product.name}</Text>
|
||||
<Text style={{ fontSize: 9, color: C.gray400 }}>{l.page} {item.pageNum}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PRODUCT PAGES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const ProductPage: React.FC<{
|
||||
product: BrochureProduct;
|
||||
locale: 'en' | 'de';
|
||||
logoBlack?: string | Buffer;
|
||||
}> = ({ product, locale, logoBlack }) => {
|
||||
const l = labels(locale);
|
||||
const desc = strip(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml);
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
<Header logo={logoBlack} right={l.overview} />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
{/* Category + Name */}
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||
{product.categories.map(c => c.name).join(' · ')}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.3, marginBottom: 8 }}>{product.name}</Text>
|
||||
<AccentBar />
|
||||
|
||||
{/* Full-width product image */}
|
||||
<View style={{
|
||||
height: 160, marginHorizontal: -MARGIN, marginBottom: 24,
|
||||
backgroundColor: C.offWhite,
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
padding: 16,
|
||||
}}>
|
||||
{product.featuredImage ? (
|
||||
<Image src={product.featuredImage} style={{ maxWidth: '80%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 10, color: C.gray400 }}>—</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Description + QR in two columns */}
|
||||
<View style={{ flexDirection: 'row', gap: 32, marginBottom: 24 }}>
|
||||
<View style={{ flex: 2 }}>
|
||||
{desc && (
|
||||
<View>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.application}</Text>
|
||||
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }}>{desc}</RichText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
{(product.qrWebsite || product.qrDatasheet) && (
|
||||
<View style={{ gap: 14 }}>
|
||||
{product.qrWebsite && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 3 }}>
|
||||
<Image src={product.qrWebsite} style={{ width: 40, height: 40 }} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 2 }}>{l.qrWeb}</Text>
|
||||
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Produktseite' : 'Product Page'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{product.qrDatasheet && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 3 }}>
|
||||
<Image src={product.qrDatasheet} style={{ width: 40, height: 40 }} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 2 }}>{l.qrPdf}</Text>
|
||||
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Technical Data */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 10 }}>{l.specs}</Text>
|
||||
|
||||
{/* Clean table header */}
|
||||
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 2 }}>
|
||||
<View style={{ width: '55%' }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.property}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.value}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{product.attributes.map((attr, i) => (
|
||||
<View key={i} style={{
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 5,
|
||||
backgroundColor: i % 2 === 0 ? C.white : C.offWhite,
|
||||
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
|
||||
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||
}}>
|
||||
<View style={{ width: '55%', paddingRight: 8 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 9, color: C.gray900 }}>{attr.options.join(', ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BACK COVER
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const BackCover: React.FC<{
|
||||
companyInfo: BrochureProps['companyInfo'];
|
||||
locale: 'en' | 'de';
|
||||
logoWhite?: string | Buffer;
|
||||
image?: string | Buffer;
|
||||
}> = ({ companyInfo, locale, logoWhite, image }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica' }}>
|
||||
{/* Background */}
|
||||
{imgValid(image) && (
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.92 }} />
|
||||
</View>
|
||||
)}
|
||||
{!imgValid(image) && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep }} />}
|
||||
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: MARGIN }}>
|
||||
{imgValid(logoWhite) ? (
|
||||
<Image src={logoWhite!} style={{ width: 160, marginBottom: 40 }} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 28, fontWeight: 700, color: C.white, letterSpacing: 3, textTransform: 'uppercase', marginBottom: 40 }}>KLZ CABLES</Text>
|
||||
)}
|
||||
|
||||
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 40 }} />
|
||||
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.contact}</Text>
|
||||
<Text style={{ fontSize: 12, color: C.white, lineHeight: 1.8, textAlign: 'center', marginBottom: 20 }}>{companyInfo.address}</Text>
|
||||
|
||||
<Text style={{ fontSize: 12, color: C.white, marginBottom: 4 }}>{companyInfo.phone}</Text>
|
||||
<Text style={{ fontSize: 12, color: C.gray300, marginBottom: 24 }}>{companyInfo.email}</Text>
|
||||
|
||||
<Text style={{ fontSize: 13, fontWeight: 700, color: C.green }}>{companyInfo.website}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ position: 'absolute', bottom: 28, left: MARGIN, right: MARGIN, alignItems: 'center' }} fixed>
|
||||
<Text style={{ fontSize: 8, color: C.gray400 }}>© {new Date().getFullYear()} KLZ Cables GmbH</Text>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DOCUMENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const PDFBrochure: React.FC<BrochureProps> = ({
|
||||
products, locale, companyInfo, introContent,
|
||||
marketingSections, logoBlack, logoWhite, galleryImages,
|
||||
}) => {
|
||||
// Calculate actual page numbers
|
||||
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
|
||||
const numInfoPages = 1 + (marketingSections?.length || 0); // About + sections
|
||||
const productStartPage = 1 + numInfoPages + 1; // Cover + info pages + TOC
|
||||
|
||||
// Assign images to sections: dark sections get indices 2,4; light get 3
|
||||
const sectionThemes: Array<'light' | 'dark'> = [];
|
||||
if (marketingSections) {
|
||||
for (let i = 0; i < marketingSections.length; i++) {
|
||||
// Alternate: light, dark, light, dark, light, dark
|
||||
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
|
||||
|
||||
{/* About page with image index 1 */}
|
||||
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} />
|
||||
|
||||
{/* Each marketing section gets its own page */}
|
||||
{marketingSections?.map((section, i) => (
|
||||
<InfoPage
|
||||
key={`info-${i}`}
|
||||
section={section}
|
||||
image={galleryImages?.[i + 2]}
|
||||
logoBlack={logoBlack}
|
||||
dark={sectionThemes[i] === 'dark'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* TOC */}
|
||||
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} image={galleryImages?.[5]} />
|
||||
|
||||
{/* Products — each on its own page */}
|
||||
{products.map(p => (
|
||||
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
|
||||
))}
|
||||
|
||||
{/* Back cover */}
|
||||
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[6]} />
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Font,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
||||
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
fonts: [
|
||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
||||
const styles = StyleSheet.create({
|
||||
@@ -288,7 +302,10 @@ const getLabels = (locale: 'en' | 'de') => {
|
||||
return labels[locale];
|
||||
};
|
||||
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
product,
|
||||
locale,
|
||||
}) => {
|
||||
const labels = getLabels(locale);
|
||||
|
||||
return (
|
||||
@@ -300,7 +317,9 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) =
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||
<Text style={styles.docTitle}>
|
||||
{labels.productDatasheet}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productRow}>
|
||||
@@ -309,8 +328,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) =
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<Text key={index} style={styles.productMeta}>
|
||||
{cat.name}
|
||||
{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
@@ -319,8 +337,12 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) =
|
||||
</View>
|
||||
<View style={styles.productImageCol}>
|
||||
{product.featuredImage ? (
|
||||
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||
<Image
|
||||
src={product.featuredImage}
|
||||
style={styles.heroImage}
|
||||
/>
|
||||
) : (
|
||||
|
||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -334,11 +356,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) =
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(
|
||||
product.applicationHtml ||
|
||||
product.shortDescriptionHtml ||
|
||||
product.descriptionHtml,
|
||||
)}
|
||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -354,14 +372,17 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) =
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||
index === product.attributes.length - 1 &&
|
||||
styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
328
lib/pdf-page.tsx
328
lib/pdf-page.tsx
@@ -1,328 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
|
||||
|
||||
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
||||
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
||||
|
||||
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
accent: '#82ed20',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const MARGIN = 72;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
color: C.gray900,
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 100,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 20,
|
||||
paddingHorizontal: MARGIN,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: C.gray200,
|
||||
},
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
logoText: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
docTitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.navy,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
productHero: {
|
||||
marginTop: 0,
|
||||
},
|
||||
|
||||
pageTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginBottom: 0,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
|
||||
accentBar: {
|
||||
width: 30,
|
||||
height: 3,
|
||||
backgroundColor: C.accent,
|
||||
marginTop: 8,
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: MARGIN,
|
||||
},
|
||||
|
||||
// Lexical Elements
|
||||
paragraph: {
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 12,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 20,
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
list: {
|
||||
marginBottom: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 4,
|
||||
},
|
||||
listItemBullet: {
|
||||
width: 12,
|
||||
fontSize: 10,
|
||||
color: C.accent,
|
||||
fontWeight: 700,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.7,
|
||||
},
|
||||
link: {
|
||||
color: C.accent,
|
||||
textDecoration: 'none',
|
||||
},
|
||||
textBold: {
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: C.navyDeep,
|
||||
},
|
||||
textItalic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Footer — matches brochure style
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: C.gray200,
|
||||
},
|
||||
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: C.gray400,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Lexical to React-PDF Renderer ────────────────────────────────
|
||||
|
||||
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case 'text': {
|
||||
const format = node.format || 0;
|
||||
const isBold = (format & 1) !== 0;
|
||||
const isItalic = (format & 2) !== 0;
|
||||
|
||||
let elementStyle: any = {};
|
||||
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
|
||||
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
|
||||
|
||||
return (
|
||||
<Text key={idx} style={elementStyle}>
|
||||
{node.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
return (
|
||||
<Text key={idx} style={styles.paragraph}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'heading': {
|
||||
let hStyle = styles.heading3;
|
||||
if (node.tag === 'h1') hStyle = styles.heading1;
|
||||
if (node.tag === 'h2') hStyle = styles.heading2;
|
||||
|
||||
return (
|
||||
<Text key={idx} style={hStyle}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
return (
|
||||
<View key={idx} style={styles.list}>
|
||||
{node.children?.map((child: any, i: number) => {
|
||||
if (child.type === 'listitem') {
|
||||
return (
|
||||
<View key={i} style={styles.listItem}>
|
||||
<Text style={styles.listItemBullet}>
|
||||
{node.listType === 'number' ? `${i + 1}.` : '•'}
|
||||
</Text>
|
||||
<Text style={styles.listItemContent}>
|
||||
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return renderLexicalNode(child, i);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
const href = node.fields?.url || node.url || '#';
|
||||
return (
|
||||
<Link key={idx} src={href} style={styles.link}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
case 'linebreak': {
|
||||
return <Text key={idx}>{'\n'}</Text>;
|
||||
}
|
||||
|
||||
// Ignore payload blocks recursively to avoid crashing
|
||||
case 'block':
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (node.children) {
|
||||
return (
|
||||
<Text key={idx}>
|
||||
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface PDFPageProps {
|
||||
page: any;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Hero Header */}
|
||||
<View style={styles.hero} fixed>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productHero}>
|
||||
<Text style={styles.pageTitle}>{page.title}</Text>
|
||||
<View style={styles.accentBar} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View>
|
||||
{page.content?.root?.children?.map((node: any, i: number) =>
|
||||
renderLexicalNode(node, i),
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Minimal footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>{dateStr}</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
106
lib/utils/technical.ts
Normal file
106
lib/utils/technical.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Utility for formatting technical data values.
|
||||
* Handles long lists of standards and simplifies repetitive strings.
|
||||
*/
|
||||
|
||||
export interface FormattedTechnicalValue {
|
||||
original: string;
|
||||
isList: boolean;
|
||||
parts: string[];
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a technical value string.
|
||||
* Detects if it's a list (separated by / or ,) and tries to clean it up.
|
||||
*/
|
||||
export function formatTechnicalValue(value: string | null | undefined): FormattedTechnicalValue {
|
||||
if (!value) {
|
||||
return { original: '', isList: false, parts: [], displayValue: '' };
|
||||
}
|
||||
|
||||
const str = String(value).trim();
|
||||
|
||||
// Detect list separators
|
||||
let parts: string[] = [];
|
||||
if (str.includes(' / ')) {
|
||||
parts = str.split(' / ').map(p => p.trim());
|
||||
} else if (str.includes(' /')) {
|
||||
parts = str.split(' /').map(p => p.trim());
|
||||
} else if (str.includes('/ ')) {
|
||||
parts = str.split('/ ').map(p => p.trim());
|
||||
} else if (str.split('/').length > 2) {
|
||||
// Check if it's actually many standards separated by / without spaces
|
||||
// e.g. EN123/EN456/EN789
|
||||
const split = str.split('/');
|
||||
if (split.length > 3) {
|
||||
parts = split.map(p => p.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// If no parts found yet, try comma
|
||||
if (parts.length === 0 && str.includes(', ')) {
|
||||
parts = str.split(', ').map(p => p.trim());
|
||||
}
|
||||
|
||||
// Filter out empty parts
|
||||
parts = parts.filter(Boolean);
|
||||
|
||||
// If we have parts, let's see if we can simplify them
|
||||
if (parts.length > 2) {
|
||||
// Find common prefix to condense repetitive standards
|
||||
let commonPrefix = '';
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
let i = 0;
|
||||
while (i < first.length && first.charAt(i) === last.charAt(i)) {
|
||||
i++;
|
||||
}
|
||||
commonPrefix = first.substring(0, i);
|
||||
|
||||
// If a meaningful prefix exists (e.g., "EN 60 332-1-")
|
||||
if (commonPrefix.length > 4) {
|
||||
// Trim trailing spaces/dashes before comparing words
|
||||
const basePrefix = commonPrefix.trim();
|
||||
const suffixParts: string[] = [];
|
||||
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
if (idx === 0) {
|
||||
suffixParts.push(parts[idx]);
|
||||
} else {
|
||||
const suffix = parts[idx].substring(commonPrefix.length).trim();
|
||||
if (suffix) {
|
||||
suffixParts.push(suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Condense into a single string like "EN 60 332-1-2 / -3 / -4"
|
||||
// Wait, returning a single string might still wrap badly.
|
||||
// Instead, we return them as chunks or just a condensed string.
|
||||
const condensedString = suffixParts[0] + ' / -' + suffixParts.slice(1).join(' / -');
|
||||
|
||||
return {
|
||||
original: str,
|
||||
isList: false, // Turn off badge rendering to use text block instead
|
||||
parts: [condensedString],
|
||||
displayValue: condensedString
|
||||
};
|
||||
}
|
||||
|
||||
// If no common prefix, return as list so UI can render badges
|
||||
return {
|
||||
original: str,
|
||||
isList: true,
|
||||
parts,
|
||||
displayValue: parts.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
original: str,
|
||||
isList: false,
|
||||
parts: [str],
|
||||
displayValue: str
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"pages": {
|
||||
"impressum": "impressum",
|
||||
"datenschutz": "datenschutz",
|
||||
"agbs": "terms",
|
||||
"agbs": "agbs",
|
||||
"kontakt": "contact",
|
||||
"team": "team",
|
||||
"blog": "blog",
|
||||
@@ -74,7 +74,7 @@
|
||||
"privacyPolicy": "Datenschutz",
|
||||
"privacyPolicySlug": "datenschutz",
|
||||
"terms": "AGB",
|
||||
"termsSlug": "terms",
|
||||
"termsSlug": "agbs",
|
||||
"products": "Produkte",
|
||||
"lowVoltage": "Niederspannungskabel",
|
||||
"mediumVoltage": "Mittelspannungskabel",
|
||||
@@ -226,6 +226,10 @@
|
||||
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
|
||||
"downloadDatasheet": "Datenblatt herunterladen",
|
||||
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
|
||||
"downloadExcel": "Excel herunterladen",
|
||||
"downloadExcelDesc": "Erhalten Sie die technischen Daten als editierbare Tabelle.",
|
||||
"downloadBrochure": "Produktbroschüre",
|
||||
"downloadBrochureDesc": "Laden Sie unseren kompletten Produktkatalog mit allen technischen Spezifikationen herunter.",
|
||||
"form": {
|
||||
"contactInfo": "Kontaktinformationen",
|
||||
"projectDetails": "Projektdetails",
|
||||
@@ -395,5 +399,21 @@
|
||||
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
},
|
||||
"Brochure": {
|
||||
"title": "Produktkatalog",
|
||||
"subtitle": "Erhalten Sie unsere komplette Produktbroschüre mit allen technischen Spezifikationen und Kabellösungen.",
|
||||
"emailPlaceholder": "ihre@email.de",
|
||||
"emailLabel": "E-Mail-Adresse",
|
||||
"submit": "Broschüre erhalten",
|
||||
"submitting": "Wird gesendet...",
|
||||
"successTitle": "Ihre Broschüre ist bereit!",
|
||||
"successDesc": "Vielen Dank für Ihr Interesse. Klicken Sie unten, um den kompletten KLZ-Produktkatalog herunterzuladen.",
|
||||
"download": "Broschüre herunterladen",
|
||||
"privacyNote": "Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
|
||||
"close": "Schließen",
|
||||
"ctaTitle": "Kompletter Produktkatalog",
|
||||
"ctaDesc": "Alle Datenblätter in einem Premium-PDF — technische Spezifikationen, Kabellösungen & mehr.",
|
||||
"ctaButton": "Kostenlose Broschüre erhalten"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"pages": {
|
||||
"legal-notice": "impressum",
|
||||
"privacy-policy": "datenschutz",
|
||||
"terms": "terms",
|
||||
"terms": "agbs",
|
||||
"contact": "contact",
|
||||
"team": "team",
|
||||
"blog": "blog",
|
||||
@@ -226,6 +226,10 @@
|
||||
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
|
||||
"downloadDatasheet": "Download Datasheet",
|
||||
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
|
||||
"downloadExcel": "Download Excel",
|
||||
"downloadExcelDesc": "Get the technical data as editable spreadsheet.",
|
||||
"downloadBrochure": "Product Brochure",
|
||||
"downloadBrochureDesc": "Download our complete product catalog with all technical specifications.",
|
||||
"form": {
|
||||
"contactInfo": "Contact Information",
|
||||
"projectDetails": "Project Details",
|
||||
@@ -395,5 +399,21 @@
|
||||
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
},
|
||||
"Brochure": {
|
||||
"title": "Product Catalog",
|
||||
"subtitle": "Get our complete product brochure with all technical specifications and cable solutions.",
|
||||
"emailPlaceholder": "your@email.com",
|
||||
"emailLabel": "Email Address",
|
||||
"submit": "Get Brochure",
|
||||
"submitting": "Sending...",
|
||||
"successTitle": "Your brochure is ready!",
|
||||
"successDesc": "Thank you for your interest. Click below to download the complete KLZ product catalog.",
|
||||
"download": "Download Brochure",
|
||||
"privacyNote": "By submitting you agree to our privacy policy.",
|
||||
"close": "Close",
|
||||
"ctaTitle": "Complete Product Catalog",
|
||||
"ctaDesc": "All datasheets in one premium PDF — technical specifications, cable solutions & more.",
|
||||
"ctaButton": "Get Free Brochure"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,12 @@ const nextConfig = {
|
||||
maxInactiveAge: 60 * 1000,
|
||||
},
|
||||
experimental: {
|
||||
staleTimes: {
|
||||
dynamic: 0,
|
||||
static: 30,
|
||||
},
|
||||
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||
cpus: 3,
|
||||
workerThreads: false,
|
||||
serverActions: {
|
||||
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
|
||||
},
|
||||
},
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
productionBrowserSourceMaps: false,
|
||||
logging: {
|
||||
fetches: {
|
||||
@@ -464,6 +458,10 @@ const nextConfig = {
|
||||
source: '/en/datenschutz',
|
||||
destination: '/en/privacy-policy',
|
||||
},
|
||||
{
|
||||
source: '/en/agbs',
|
||||
destination: '/en/terms',
|
||||
},
|
||||
],
|
||||
afterFiles: [],
|
||||
fallback: [],
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
|
||||
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
|
||||
"cms:migrate": "payload migrate",
|
||||
"cms:seed": "tsx ./scripts/seed-payload.ts",
|
||||
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
|
||||
@@ -139,7 +141,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.2.14",
|
||||
"version": "2.0.2",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
@@ -161,4 +163,4 @@
|
||||
"peerDependencies": {
|
||||
"lucide-react": "^0.563.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,9 +87,7 @@ export interface Config {
|
||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents':
|
||||
| PayloadLockedDocumentsSelect<false>
|
||||
| PayloadLockedDocumentsSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
@@ -100,9 +98,6 @@ export interface Config {
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: 'de' | 'en';
|
||||
widgets: {
|
||||
collections: CollectionsWidget;
|
||||
};
|
||||
user: User;
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
@@ -333,14 +328,6 @@ export interface Page {
|
||||
layout?: ('default' | 'fullBleed') | null;
|
||||
excerpt?: string | null;
|
||||
featuredImage?: (number | null) | Media;
|
||||
/**
|
||||
* If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).
|
||||
*/
|
||||
redirectUrl?: string | null;
|
||||
/**
|
||||
* Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.
|
||||
*/
|
||||
redirectPermanent?: boolean | null;
|
||||
content: {
|
||||
root: {
|
||||
type: string;
|
||||
@@ -587,8 +574,6 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
layout?: T;
|
||||
excerpt?: T;
|
||||
featuredImage?: T;
|
||||
redirectUrl?: T;
|
||||
redirectPermanent?: T;
|
||||
content?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
@@ -634,16 +619,6 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
*/
|
||||
export interface CollectionsWidget {
|
||||
data?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
width: 'full';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "StatsBlock".
|
||||
@@ -982,6 +957,7 @@ export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,6 @@ export default buildConfig({
|
||||
},
|
||||
db: postgresAdapter({
|
||||
prodMigrations: migrations,
|
||||
migrationDir:
|
||||
process.env.NODE_ENV === 'production' ? undefined : path.resolve(dirname, 'src/migrations'),
|
||||
pool: {
|
||||
connectionString:
|
||||
process.env.DATABASE_URI ||
|
||||
|
||||
3541
pnpm-lock.yaml
generated
3541
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/brochure/klz-product-catalog-de.pdf
Normal file
BIN
public/brochure/klz-product-catalog-de.pdf
Normal file
Binary file not shown.
BIN
public/brochure/klz-product-catalog-en.pdf
Normal file
BIN
public/brochure/klz-product-catalog-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/products/h1z2z2-k-de.xlsx
Normal file
BIN
public/datasheets/products/h1z2z2-k-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/h1z2z2-k-en.xlsx
Normal file
BIN
public/datasheets/products/h1z2z2-k-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2x2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2x2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2x2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2x2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xfk2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2xfk2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xfk2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2xfk2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xfkld2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2xfkld2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xfkld2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2xfkld2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xs2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2xs2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xs2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2xs2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsf2y-de.xlsx
Normal file
BIN
public/datasheets/products/n2xsf2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsf2y-en.xlsx
Normal file
BIN
public/datasheets/products/n2xsf2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsfl2y-hv-de.xlsx
Normal file
BIN
public/datasheets/products/n2xsfl2y-hv-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsfl2y-hv-en.xlsx
Normal file
BIN
public/datasheets/products/n2xsfl2y-hv-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsfl2y-mv-de.xlsx
Normal file
BIN
public/datasheets/products/n2xsfl2y-mv-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsfl2y-mv-en.xlsx
Normal file
BIN
public/datasheets/products/n2xsfl2y-mv-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsy-de.xlsx
Normal file
BIN
public/datasheets/products/n2xsy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xsy-en.xlsx
Normal file
BIN
public/datasheets/products/n2xsy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xy-de.xlsx
Normal file
BIN
public/datasheets/products/n2xy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/n2xy-en.xlsx
Normal file
BIN
public/datasheets/products/n2xy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2x2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2x2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2x2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2x2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xfk2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2xfk2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xfk2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2xfk2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xfkld2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2xfkld2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xfkld2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2xfkld2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xs2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2xs2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xs2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2xs2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsf2y-de.xlsx
Normal file
BIN
public/datasheets/products/na2xsf2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsf2y-en.xlsx
Normal file
BIN
public/datasheets/products/na2xsf2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsfl2y-hv-de.xlsx
Normal file
BIN
public/datasheets/products/na2xsfl2y-hv-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsfl2y-hv-en.xlsx
Normal file
BIN
public/datasheets/products/na2xsfl2y-hv-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsfl2y-mv-de.xlsx
Normal file
BIN
public/datasheets/products/na2xsfl2y-mv-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsfl2y-mv-en.xlsx
Normal file
BIN
public/datasheets/products/na2xsfl2y-mv-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsy-de.xlsx
Normal file
BIN
public/datasheets/products/na2xsy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xsy-en.xlsx
Normal file
BIN
public/datasheets/products/na2xsy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xy-de.xlsx
Normal file
BIN
public/datasheets/products/na2xy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/na2xy-en.xlsx
Normal file
BIN
public/datasheets/products/na2xy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nay2y-de.xlsx
Normal file
BIN
public/datasheets/products/nay2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nay2y-en.xlsx
Normal file
BIN
public/datasheets/products/nay2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/naycwy-de.xlsx
Normal file
BIN
public/datasheets/products/naycwy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/naycwy-en.xlsx
Normal file
BIN
public/datasheets/products/naycwy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nayy-de.xlsx
Normal file
BIN
public/datasheets/products/nayy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nayy-en.xlsx
Normal file
BIN
public/datasheets/products/nayy-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/ny2y-de.xlsx
Normal file
BIN
public/datasheets/products/ny2y-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/ny2y-en.xlsx
Normal file
BIN
public/datasheets/products/ny2y-en.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nycwy-de.xlsx
Normal file
BIN
public/datasheets/products/nycwy-de.xlsx
Normal file
Binary file not shown.
BIN
public/datasheets/products/nycwy-en.xlsx
Normal file
BIN
public/datasheets/products/nycwy-en.xlsx
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user