Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02be8e59b2 | ||
|
|
d2418b5720 | ||
|
|
501f9659a1 | ||
|
|
e9ceae3989 | ||
|
|
ec3f2cf8c9 |
@@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
|||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import { Suspense } from 'react';
|
|
||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { config } from '@/lib/config';
|
|
||||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
@@ -61,6 +59,7 @@ export const viewport: Viewport = {
|
|||||||
themeColor: '#001a4d',
|
themeColor: '#001a4d',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import AutoBrochureModal from '@/components/AutoBrochureModal';
|
||||||
export default async function Layout(props: {
|
export default async function Layout(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
@@ -77,7 +76,7 @@ export default async function Layout(props: {
|
|||||||
let messages: Record<string, any> = {};
|
let messages: Record<string, any> = {};
|
||||||
try {
|
try {
|
||||||
messages = await getMessages();
|
messages = await getMessages();
|
||||||
} catch (error) {
|
} catch {
|
||||||
messages = {};
|
messages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +160,8 @@ export default async function Layout(props: {
|
|||||||
|
|
||||||
<AnalyticsShell />
|
<AnalyticsShell />
|
||||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||||
|
|
||||||
|
<AutoBrochureModal />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<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">
|
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -107,13 +106,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => (
|
||||||
<span className="relative inline-block">
|
<span className="text-accent italic">{chunks}</span>
|
||||||
<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>
|
</Heading>
|
||||||
@@ -223,7 +216,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="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="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">
|
<div className="max-w-2xl text-center lg:text-left">
|
||||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||||
{t('cta.title')}
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
<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">
|
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||||
{t('michael.role')}
|
{t('michael.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
<span className="text-white">{t('michael.name')}</span>
|
<span className="text-white">{t('michael.name')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<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" />
|
<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-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||||
{t('michael.quote')}
|
{t('michael.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('michael.name')}
|
alt={t('michael.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
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" />
|
<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" />
|
||||||
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('klaus.name')}
|
alt={t('klaus.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
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" />
|
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||||
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||||
{t('klaus.role')}
|
{t('klaus.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
{t('klaus.name')}
|
{t('klaus.name')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<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" />
|
<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-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||||
{t('klaus.quote')}
|
{t('klaus.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+97
-61
@@ -3,76 +3,112 @@
|
|||||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
|
||||||
export async function requestBrochureAction(formData: FormData) {
|
export async function requestBrochureAction(formData: FormData) {
|
||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||||
|
|
||||||
const { headers } = await import('next/headers');
|
const { headers } = await import('next/headers');
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
if ('setServerContext' in services.analytics) {
|
if ('setServerContext' in services.analytics) {
|
||||||
(services.analytics as any).setServerContext({
|
(services.analytics as any).setServerContext({
|
||||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
referrer: requestHeaders.get('referer') || undefined,
|
referrer: requestHeaders.get('referer') || undefined,
|
||||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
services.analytics.track('brochure-request-attempt');
|
services.analytics.track('brochure-request-attempt');
|
||||||
|
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const locale = (formData.get('locale') as string) || 'en';
|
const locale = (formData.get('locale') as string) || 'en';
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
logger.warn('Missing email in brochure request');
|
logger.warn('Missing email in brochure request');
|
||||||
return { success: false, error: 'Missing email address' };
|
return { success: false, error: 'Missing email address' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic email validation
|
// Basic email validation
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
return { success: false, error: 'Invalid email address' };
|
return { success: false, error: 'Invalid email address' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Save to CMS
|
// 1. Save to CMS
|
||||||
try {
|
try {
|
||||||
const { getPayload } = await import('payload');
|
const { getPayload } = await import('payload');
|
||||||
const configPromise = (await import('@payload-config')).default;
|
const configPromise = (await import('@payload-config')).default;
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
await payload.create({
|
await payload.create({
|
||||||
collection: 'form-submissions',
|
collection: 'form-submissions',
|
||||||
data: {
|
data: {
|
||||||
name: email.split('@')[0],
|
name: email.split('@')[0],
|
||||||
email,
|
email,
|
||||||
message: `Brochure download request (${locale})`,
|
message: `Brochure download request (${locale})`,
|
||||||
type: 'brochure_download' as any,
|
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
|
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||||
const brochureUrl = `/brochure/klz-product-catalog-${locale}.pdf`;
|
} catch (error) {
|
||||||
|
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||||
|
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, brochureUrl };
|
// 2. Notify via Gotify
|
||||||
|
try {
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: '📑 Brochure Download Request',
|
||||||
|
message: `New brochure download request from ${email} (${locale})`,
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send notification', { error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send Brochure via Email
|
||||||
|
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||||
|
const { render } = await import('@mintel/mail');
|
||||||
|
const React = await import('react');
|
||||||
|
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
React.createElement(BrochureDeliveryEmail, {
|
||||||
|
email,
|
||||||
|
brochureUrl,
|
||||||
|
locale: locale as 'en' | 'de',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailResult = await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailResult.success) {
|
||||||
|
logger.info('Brochure email sent successfully', { email });
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||||
|
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||||
|
action: 'requestBrochureAction_email',
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Exception while sending brochure email', { error });
|
||||||
|
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Track success
|
||||||
|
services.analytics.track('brochure-request-success', {
|
||||||
|
locale,
|
||||||
|
delivery_method: 'email',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||||
|
|
||||||
|
export default function AutoBrochureModal() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user has already seen or interacted with the modal
|
||||||
|
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
|
||||||
|
|
||||||
|
if (!hasSeenModal) {
|
||||||
|
// Auto-open after 5 seconds to not interrupt immediate page load
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
// Mark as seen so it doesn't bother them again on next page load
|
||||||
|
localStorage.setItem('klz_brochure_modal_seen', 'true');
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
|
||||||
|
}
|
||||||
+418
-213
@@ -9,8 +9,8 @@ import { useAnalytics } from './analytics/useAnalytics';
|
|||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,235 +19,440 @@ interface Props {
|
|||||||
* No direct download link is exposed anywhere.
|
* No direct download link is exposed anywhere.
|
||||||
*/
|
*/
|
||||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||||
const t = useTranslations('Brochure');
|
const t = useTranslations('Brochure');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
|
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
|
||||||
const [url, setUrl] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [err, setErr] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => { setMounted(true); }, []);
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
function openModal() {
|
||||||
if (!open) return;
|
setOpen(true);
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); };
|
}
|
||||||
document.addEventListener('keydown', onKey);
|
function closeModal() {
|
||||||
document.body.style.overflow = 'hidden';
|
setOpen(false);
|
||||||
return () => {
|
setPhase('form');
|
||||||
document.removeEventListener('keydown', onKey);
|
setErr('');
|
||||||
document.body.style.overflow = '';
|
}
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
function openModal() { setOpen(true); }
|
useEffect(() => {
|
||||||
function closeModal() {
|
if (!open) return;
|
||||||
setOpen(false);
|
const onKey = (e: KeyboardEvent) => {
|
||||||
setPhase('form');
|
if (e.key === 'Escape') closeModal();
|
||||||
setUrl('');
|
};
|
||||||
setErr('');
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
// ── Trigger Button ─────────────────────────────────────────────────
|
||||||
e.preventDefault();
|
const trigger = (
|
||||||
if (!formRef.current) return;
|
<div className={cn(className)}>
|
||||||
setPhase('loading');
|
<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" />
|
||||||
|
|
||||||
const fd = new FormData(formRef.current);
|
{/* Icon */}
|
||||||
fd.set('locale', locale);
|
<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>
|
||||||
|
|
||||||
try {
|
{/* Labels */}
|
||||||
const res = await requestBrochureAction(fd);
|
<span className="flex-1 min-w-0">
|
||||||
if (res.success && res.brochureUrl) {
|
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
|
||||||
setUrl(res.brochureUrl);
|
PDF Katalog
|
||||||
setPhase('success');
|
</span>
|
||||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
<span
|
||||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
className={cn(
|
||||||
file_type: 'brochure',
|
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||||
location: 'brochure_modal',
|
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||||
});
|
)}
|
||||||
} else {
|
>
|
||||||
setErr(res.error || 'Error');
|
{t('ctaTitle')}
|
||||||
setPhase('error');
|
</span>
|
||||||
}
|
</span>
|
||||||
} catch {
|
|
||||||
setErr('Network error');
|
|
||||||
setPhase('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Trigger Button ─────────────────────────────────────────────────
|
{/* Arrow */}
|
||||||
const trigger = (
|
<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">
|
||||||
<div className={cn(className)}>
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<button
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||||
type="button"
|
</svg>
|
||||||
onClick={openModal}
|
</span>
|
||||||
className={cn(
|
</button>
|
||||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
</div>
|
||||||
'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 */}
|
// ── Modal ──────────────────────────────────────────────────────────
|
||||||
<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">
|
const modal =
|
||||||
<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">
|
mounted && open
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
? createPortal(
|
||||||
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" />
|
<div
|
||||||
</svg>
|
style={{
|
||||||
</span>
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
{/* Labels */}
|
zIndex: 9999,
|
||||||
<span className="flex-1 min-w-0">
|
display: 'flex',
|
||||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">PDF Katalog</span>
|
alignItems: 'center',
|
||||||
<span className={cn(
|
justifyContent: 'center',
|
||||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
padding: '1rem',
|
||||||
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 */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
|
style={{
|
||||||
onClick={closeModal}
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.75)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
}}
|
||||||
|
onClick={closeModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Panel */}
|
{/* 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' }}>
|
<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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Green top bar */}
|
{/* Close */}
|
||||||
<div style={{ height: '3px', background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)' }} />
|
<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>
|
||||||
|
|
||||||
{/* Close */}
|
<div style={{ padding: '2rem' }}>
|
||||||
<button
|
{/* Header */}
|
||||||
type="button"
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
onClick={closeModal}
|
<div
|
||||||
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' }}
|
style={{
|
||||||
aria-label={t('close')}
|
display: 'flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
justifyContent: 'center',
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
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>
|
</svg>
|
||||||
</button>
|
</div>
|
||||||
|
<h2
|
||||||
<div style={{ padding: '2rem' }}>
|
style={{
|
||||||
{/* Header */}
|
margin: 0,
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
fontSize: '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' }}>
|
fontWeight: 900,
|
||||||
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
color: '#fff',
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
textTransform: 'uppercase',
|
||||||
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" />
|
letterSpacing: '-0.03em',
|
||||||
</svg>
|
lineHeight: 1,
|
||||||
</div>
|
marginBottom: '0.5rem',
|
||||||
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1, marginBottom: '0.5rem' }}>
|
}}
|
||||||
{t('title')}
|
>
|
||||||
</h2>
|
{t('title')}
|
||||||
<p style={{ margin: 0, fontSize: '0.875rem', color: 'rgba(255,255,255,0.5)', lineHeight: 1.6 }}>
|
</h2>
|
||||||
{t('subtitle')}
|
<p
|
||||||
</p>
|
style={{
|
||||||
</div>
|
margin: 0,
|
||||||
|
fontSize: '0.875rem',
|
||||||
{phase === 'success' ? (
|
color: 'rgba(255,255,255,0.5)',
|
||||||
<div>
|
lineHeight: 1.6,
|
||||||
<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">
|
{t('subtitle')}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
</p>
|
||||||
</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>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
{phase === 'success' ? (
|
||||||
<>
|
<div>
|
||||||
{trigger}
|
<div
|
||||||
{modal}
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.125rem 0 0',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locale === 'de'
|
||||||
|
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||||
|
: 'Please check your inbox.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('close')}
|
||||||
|
</button>
|
||||||
|
</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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-186
@@ -9,203 +9,216 @@ import { useAnalytics } from './analytics/useAnalytics';
|
|||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
interface BrochureModalProps {
|
interface BrochureModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||||
const t = useTranslations('Brochure');
|
const t = useTranslations('Brochure');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
// Close on escape + lock scroll
|
||||||
const [brochureUrl, setBrochureUrl] = useState<string | null>(null);
|
useEffect(() => {
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
if (isOpen) {
|
||||||
const handleClose = () => {
|
document.addEventListener('keydown', handleEsc);
|
||||||
setState('idle');
|
document.body.style.overflow = 'hidden';
|
||||||
setBrochureUrl(null);
|
} else {
|
||||||
setErrorMsg('');
|
document.body.style.overflow = '';
|
||||||
onClose();
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEsc);
|
||||||
|
document.body.style.overflow = '';
|
||||||
};
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
if (!mounted || !isOpen) return null;
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formRef.current) return;
|
||||||
|
|
||||||
const modal = (
|
setState('submitting');
|
||||||
<div
|
setErrorMsg('');
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
|
||||||
role="dialog"
|
try {
|
||||||
aria-modal="true"
|
const formData = new FormData(formRef.current);
|
||||||
|
formData.set('locale', locale);
|
||||||
|
|
||||||
|
const result = await requestBrochureAction(formData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setState('success');
|
||||||
|
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');
|
||||||
|
setErrorMsg('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!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')}
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div
|
<path
|
||||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
strokeLinecap="round"
|
||||||
onClick={handleClose}
|
strokeLinejoin="round"
|
||||||
aria-hidden="true"
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
/>
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Modal Panel */}
|
<div className="p-8 pt-7">
|
||||||
<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">
|
{/* Icon + Header */}
|
||||||
{/* Accent bar at top */}
|
<div className="mb-7">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||||
|
<svg
|
||||||
{/* Close Button */}
|
className="h-6 w-6 text-[#82ed20]"
|
||||||
<button
|
fill="none"
|
||||||
type="button"
|
stroke="currentColor"
|
||||||
onClick={handleClose}
|
viewBox="0 0 24 24"
|
||||||
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')}
|
<path
|
||||||
>
|
strokeLinecap="round"
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
strokeLinejoin="round"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
strokeWidth={1.5}
|
||||||
</svg>
|
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"
|
||||||
</button>
|
/>
|
||||||
|
</svg>
|
||||||
<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>
|
||||||
</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>
|
||||||
|
|
||||||
return createPortal(modal, document.body);
|
{state === 'success' ? (
|
||||||
|
<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]">
|
||||||
|
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/50 mt-0.5">
|
||||||
|
{locale === 'de'
|
||||||
|
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||||
|
: 'Please check your inbox.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
|
||||||
|
>
|
||||||
|
{t('close')}
|
||||||
|
</button>
|
||||||
|
</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,23 +57,23 @@ const jsxConverters: JSXConverters = {
|
|||||||
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
|
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
|
||||||
const id = textContent
|
const id = textContent
|
||||||
? textContent
|
? textContent
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/ä/g, 'ae')
|
.replace(/ä/g, 'ae')
|
||||||
.replace(/ö/g, 'oe')
|
.replace(/ö/g, 'oe')
|
||||||
.replace(/ü/g, 'ue')
|
.replace(/ü/g, 'ue')
|
||||||
.replace(/ß/g, 'ss')
|
.replace(/ß/g, 'ss')
|
||||||
.replace(/[*_`]/g, '')
|
.replace(/[*_`]/g, '')
|
||||||
.replace(/[^\w\s-]/g, '')
|
.replace(/[^\w\s-]/g, '')
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (tag === 'h1')
|
if (tag === 'h1')
|
||||||
return (
|
return (
|
||||||
<h2
|
<h2
|
||||||
id={id}
|
id={id}
|
||||||
className="text-3xl md:text-4xl font-bold mt-12 mb-6 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}
|
{children}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -82,7 +82,7 @@ const jsxConverters: JSXConverters = {
|
|||||||
return (
|
return (
|
||||||
<h3
|
<h3
|
||||||
id={id}
|
id={id}
|
||||||
className="text-2xl md:text-3xl font-bold mt-10 mb-5 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}
|
{children}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -91,7 +91,7 @@ const jsxConverters: JSXConverters = {
|
|||||||
return (
|
return (
|
||||||
<h4
|
<h4
|
||||||
id={id}
|
id={id}
|
||||||
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface BrochureDeliveryEmailProps {
|
||||||
|
_email: string;
|
||||||
|
brochureUrl: string;
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BrochureDeliveryEmail = ({
|
||||||
|
_email,
|
||||||
|
brochureUrl,
|
||||||
|
locale = 'en',
|
||||||
|
}: BrochureDeliveryEmailProps) => {
|
||||||
|
const t =
|
||||||
|
locale === 'de'
|
||||||
|
? {
|
||||||
|
subject: 'Ihr KLZ Kabelkatalog',
|
||||||
|
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
|
||||||
|
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||||
|
button: 'Katalog herunterladen',
|
||||||
|
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
subject: 'Your KLZ Cable Catalog',
|
||||||
|
greeting: 'Thank you for your interest in KLZ Cables.',
|
||||||
|
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||||
|
button: 'Download Catalog',
|
||||||
|
footer: 'This email was sent from klz-cables.com.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{t.subject}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Section style={headerSection}>
|
||||||
|
<Heading style={h1}>{t.subject}</Heading>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={section}>
|
||||||
|
<Text style={text}>
|
||||||
|
<strong>{t.greeting}</strong>
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>{t.body}</Text>
|
||||||
|
|
||||||
|
<Section style={buttonContainer}>
|
||||||
|
<Button style={button} href={brochureUrl}>
|
||||||
|
{t.button}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={hr} />
|
||||||
|
</Section>
|
||||||
|
<Text style={footer}>{t.footer}</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrochureDeliveryEmail;
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
backgroundColor: '#f6f9fc',
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '0 0 48px',
|
||||||
|
marginBottom: '64px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid #e6ebf1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerSection = {
|
||||||
|
backgroundColor: '#000d26',
|
||||||
|
padding: '32px 48px',
|
||||||
|
borderBottom: '4px solid #4da612',
|
||||||
|
};
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
margin: '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const section = {
|
||||||
|
padding: '32px 48px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
color: '#333',
|
||||||
|
fontSize: '16px',
|
||||||
|
lineHeight: '24px',
|
||||||
|
textAlign: 'left' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonContainer = {
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginTop: '32px',
|
||||||
|
marginBottom: '32px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = {
|
||||||
|
backgroundColor: '#4da612',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textDecoration: 'none',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '16px 32px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hr = {
|
||||||
|
borderColor: '#e6ebf1',
|
||||||
|
margin: '20px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = {
|
||||||
|
color: '#8898aa',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: '16px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginTop: '20px',
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
|
|||||||
fill
|
fill
|
||||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
<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" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
@@ -20,7 +19,7 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
<div>
|
<div>
|
||||||
<Heading
|
<Heading
|
||||||
level={1}
|
level={1}
|
||||||
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]"
|
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"
|
||||||
>
|
>
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span
|
<span
|
||||||
@@ -28,27 +27,19 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
__html: data.title
|
__html: data.title
|
||||||
.replace(
|
.replace(
|
||||||
/<green>/g,
|
/<green>/g,
|
||||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
'<span class="text-accent italic">',
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
/<\/green>/g,
|
/<\/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>',
|
'</span>',
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => (
|
||||||
<span className="relative inline-block">
|
<span className="text-accent italic">
|
||||||
<span className="relative z-10 text-accent italic inline-block">
|
{chunks}
|
||||||
{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>
|
</span>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
|||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom"
|
className="object-cover scale-105 animate-slow-zoom"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
<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" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VideoSection({ data }: { data?: any }) {
|
export default function VideoSection({ data }: { data?: any }) {
|
||||||
@@ -41,17 +40,11 @@ export default function VideoSection({ data }: { data?: any }) {
|
|||||||
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
<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]">
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<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>') }} />
|
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="italic text-accent">').replace(/<\/future>/g, '</span>') }} />
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
future: (chunks) => (
|
future: (chunks) => (
|
||||||
<span className="relative inline-block mx-2">
|
<span className="italic text-accent">{chunks}</span>
|
||||||
<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>
|
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+400
-227
@@ -72,7 +72,9 @@ export interface BrochureProps {
|
|||||||
highlights?: Array<{ value: string; label: string }>;
|
highlights?: Array<{ value: string; label: string }>;
|
||||||
pullQuote?: string;
|
pullQuote?: string;
|
||||||
}>;
|
}>;
|
||||||
galleryImages?: Array<string | Buffer>;
|
galleryImages?: Array<string | Buffer | undefined>;
|
||||||
|
messages?: Record<string, any>;
|
||||||
|
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -86,19 +88,39 @@ const imgValid = (src?: string | Buffer): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
|
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
|
||||||
catalog: 'Produktkatalog',
|
catalog: 'Kabelkatalog',
|
||||||
subtitle: 'Hochwertige Stromkabel\nMittelspannungslösungen\nSolarkabel',
|
subtitle: 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST – MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.',
|
||||||
about: 'Über uns', toc: 'Produktübersicht', overview: 'Produktübersicht',
|
about: 'Über uns',
|
||||||
application: 'Anwendung', specs: 'Technische Daten', contact: 'Kontakt',
|
toc: 'Inhalt',
|
||||||
qrWeb: 'Web', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'S.',
|
overview: 'Übersicht',
|
||||||
property: 'Eigenschaft', value: 'Wert',
|
application: 'Anwendungsbereich',
|
||||||
|
specs: 'Technische Daten',
|
||||||
|
contact: 'Kontakt',
|
||||||
|
qrWeb: 'Details',
|
||||||
|
qrPdf: 'PDF',
|
||||||
|
values: 'Unsere Werte',
|
||||||
|
edition: 'Ausgabe',
|
||||||
|
page: 'Seite',
|
||||||
|
property: 'Eigenschaft',
|
||||||
|
value: 'Wert',
|
||||||
|
other: 'Sonstige'
|
||||||
} : {
|
} : {
|
||||||
catalog: 'Product Catalog',
|
catalog: 'Cable Catalog',
|
||||||
subtitle: 'High-Quality Power Cables\nMedium Voltage Solutions\nSolar Cables',
|
subtitle: 'WE ENSURE THE CURRENT FLOWS – WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.',
|
||||||
about: 'About Us', toc: 'Product Overview', overview: 'Product Overview',
|
about: 'About Us',
|
||||||
application: 'Application', specs: 'Technical Data', contact: 'Contact',
|
toc: 'Contents',
|
||||||
qrWeb: 'Web', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'p.',
|
overview: 'Overview',
|
||||||
property: 'Property', value: 'Value',
|
application: 'Application',
|
||||||
|
specs: 'Technical Data',
|
||||||
|
contact: 'Contact',
|
||||||
|
qrWeb: 'Details',
|
||||||
|
qrPdf: 'PDF',
|
||||||
|
values: 'Our Values',
|
||||||
|
edition: 'Edition',
|
||||||
|
page: 'Page',
|
||||||
|
property: 'Property',
|
||||||
|
value: 'Value',
|
||||||
|
other: 'Other'
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Rich Text ──────────────────────────────────────────────────────────────
|
// ─── Rich Text ──────────────────────────────────────────────────────────────
|
||||||
@@ -163,6 +185,69 @@ const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({
|
|||||||
// Green accent bar
|
// Green accent bar
|
||||||
const AccentBar = () => <View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 16 }} />;
|
const AccentBar = () => <View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 16 }} />;
|
||||||
|
|
||||||
|
// ─── FadeImage ─────────────────────────────────────────────────────────────
|
||||||
|
// Simulates a gradient fade at one edge using stacked opacity bands.
|
||||||
|
// React-pdf has no CSS gradient support, so we stack 14 semi-opaque rectangles.
|
||||||
|
//
|
||||||
|
// 'position' param: which edge fades INTO the page background
|
||||||
|
// 'bottom' → image visible at top, fades down into bgColor
|
||||||
|
// 'top' → image visible at bottom, fades up into bgColor
|
||||||
|
// 'right' → image on left side, fades right into bgColor
|
||||||
|
//
|
||||||
|
// The component must be placed ABSOLUTE (position: 'absolute') on the page.
|
||||||
|
|
||||||
|
const FadeImage: React.FC<{
|
||||||
|
src: string | Buffer;
|
||||||
|
top?: number; left?: number; right?: number; bottom?: number;
|
||||||
|
width: number | string;
|
||||||
|
height: number;
|
||||||
|
fadeEdge: 'bottom' | 'top' | 'right' | 'left';
|
||||||
|
fadeSize?: number; // how many points the fade spans
|
||||||
|
bgColor: string;
|
||||||
|
opacity?: number; // overall image darkness (0–1, applied via overlay)
|
||||||
|
}> = ({ src, top, left, right, bottom, width, height, fadeEdge, fadeSize = 120, bgColor, opacity = 0 }) => {
|
||||||
|
const STEPS = 40; // High number of overlapping bands
|
||||||
|
|
||||||
|
const bands = Array.from({ length: STEPS }, (_, i) => {
|
||||||
|
// i=0 is the widest band reaching deepest into the image.
|
||||||
|
// i=STEPS-1 is the narrowest band right at the fade edge.
|
||||||
|
// Because they all anchor at the edge and overlap, their opacity compounds.
|
||||||
|
// We use an ease-in curve for distance to make the fade look natural.
|
||||||
|
const t = 1.0 / STEPS;
|
||||||
|
const easeDist = Math.pow((i + 1) / STEPS, 1.2);
|
||||||
|
const dist = fadeSize * easeDist;
|
||||||
|
|
||||||
|
const style: any = {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
opacity: t,
|
||||||
|
};
|
||||||
|
|
||||||
|
// All bands anchor at the fade edge and extend inward by `dist`
|
||||||
|
if (fadeEdge === 'bottom') {
|
||||||
|
Object.assign(style, { left: 0, right: 0, height: dist, bottom: 0 });
|
||||||
|
} else if (fadeEdge === 'top') {
|
||||||
|
Object.assign(style, { left: 0, right: 0, height: dist, top: 0 });
|
||||||
|
} else if (fadeEdge === 'right') {
|
||||||
|
Object.assign(style, { top: 0, bottom: 0, width: dist, right: 0 });
|
||||||
|
} else {
|
||||||
|
Object.assign(style, { top: 0, bottom: 0, width: dist, left: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ position: 'absolute', top, left, right, bottom, width, height }}>
|
||||||
|
<Image src={src} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
{/* Overlay using bgColor to "wash out" / dilute the image */}
|
||||||
|
{opacity > 0 && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: bgColor, opacity }} />}
|
||||||
|
{/* Gradient fade bands */}
|
||||||
|
{bands.map((s, i) => <View key={i} style={s} />)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// PAGE 1: COVER
|
// PAGE 1: COVER
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -171,7 +256,7 @@ const CoverPage: React.FC<{
|
|||||||
locale: 'en' | 'de';
|
locale: 'en' | 'de';
|
||||||
introContent?: BrochureProps['introContent'];
|
introContent?: BrochureProps['introContent'];
|
||||||
logoWhite?: string | Buffer;
|
logoWhite?: string | Buffer;
|
||||||
galleryImages?: Array<string | Buffer>;
|
galleryImages?: Array<string | Buffer | undefined>;
|
||||||
}> = ({ locale, introContent, logoWhite, galleryImages }) => {
|
}> = ({ locale, introContent, logoWhite, galleryImages }) => {
|
||||||
const l = labels(locale);
|
const l = labels(locale);
|
||||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' });
|
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { year: 'numeric', month: 'long' });
|
||||||
@@ -199,10 +284,10 @@ const CoverPage: React.FC<{
|
|||||||
{/* Main title block — bottom third of page */}
|
{/* Main title block — bottom third of page */}
|
||||||
<View style={{ position: 'absolute', bottom: 160, left: MARGIN, right: MARGIN }}>
|
<View style={{ position: 'absolute', bottom: 160, left: MARGIN, right: MARGIN }}>
|
||||||
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 24 }} />
|
<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 }}>
|
<Text style={{ fontSize: 32, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -0.5, lineHeight: 1.05 }}>
|
||||||
{l.catalog}
|
{l.catalog}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 14, color: C.gray300, lineHeight: 1.8, marginTop: 20, maxWidth: 340 }}>
|
<Text style={{ fontSize: 12, color: C.gray300, lineHeight: 1.8, marginTop: 16, maxWidth: 340 }}>
|
||||||
{introContent?.excerpt || l.subtitle}
|
{introContent?.excerpt || l.subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -224,131 +309,220 @@ const InfoPage: React.FC<{
|
|||||||
section: NonNullable<BrochureProps['marketingSections']>[0];
|
section: NonNullable<BrochureProps['marketingSections']>[0];
|
||||||
image?: string | Buffer;
|
image?: string | Buffer;
|
||||||
logoBlack?: string | Buffer;
|
logoBlack?: string | Buffer;
|
||||||
|
logoWhite?: string | Buffer;
|
||||||
dark?: boolean;
|
dark?: boolean;
|
||||||
}> = ({ section, image, logoBlack, dark }) => {
|
imagePosition?: 'top' | 'bottom-half';
|
||||||
|
}> = ({ section, image, logoBlack, logoWhite, dark, imagePosition = 'top' }) => {
|
||||||
const bg = dark ? C.navyDeep : C.white;
|
const bg = dark ? C.navyDeep : C.white;
|
||||||
const textColor = dark ? C.gray300 : C.gray600;
|
const textColor = dark ? C.gray300 : C.gray600;
|
||||||
const titleColor = dark ? C.white : C.navyDeep;
|
const titleColor = dark ? C.white : C.navyDeep;
|
||||||
const boldColor = dark ? C.white : C.navyDeep;
|
const boldColor = dark ? C.white : C.navyDeep;
|
||||||
|
const headerLogo = dark ? (logoWhite || logoBlack) : logoBlack;
|
||||||
|
|
||||||
|
// Image at top: 240pt tall, content starts below via paddingTop
|
||||||
|
const IMG_TOP_H = 240;
|
||||||
|
const bodyTopWithImg = imagePosition === 'top' && imgValid(image)
|
||||||
|
? IMG_TOP_H + 24 // content starts below image
|
||||||
|
: BODY_TOP;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||||
<Header logo={logoBlack} right="KLZ Cables" dark={dark} />
|
{/* Absolute image — from page edge, fades into bg */}
|
||||||
|
{imgValid(image) && imagePosition === 'top' && (
|
||||||
|
<FadeImage
|
||||||
|
src={image!}
|
||||||
|
top={0} left={0} right={0}
|
||||||
|
width="100%" height={IMG_TOP_H}
|
||||||
|
fadeEdge="bottom" fadeSize={120}
|
||||||
|
bgColor={bg}
|
||||||
|
opacity={dark ? 0.85 : 0.9} // EXTREMELY high opacity of bgColor to make image incredibly subtle
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{imgValid(image) && imagePosition === 'bottom-half' && (
|
||||||
|
<FadeImage
|
||||||
|
src={image!}
|
||||||
|
bottom={FOOTER_H + 20} left={0} right={0}
|
||||||
|
width="100%" height={340}
|
||||||
|
fadeEdge="top" fadeSize={140}
|
||||||
|
bgColor={bg}
|
||||||
|
opacity={dark ? 0.85 : 0.9} // Extremely subtle
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header — on top of image */}
|
||||||
|
<Header logo={headerLogo} right="KLZ Cables" dark={dark} />
|
||||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" dark={dark} />
|
<PageFooter left="KLZ Cables" right="www.klz-cables.com" dark={dark} />
|
||||||
|
|
||||||
{/* Full-width image at top */}
|
{/* Content — pushed below image when top-position */}
|
||||||
{imgValid(image) && (
|
<View style={{ paddingTop: bodyTopWithImg }}>
|
||||||
<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 */}
|
{/* Label + Title */}
|
||||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
|
<Text style={{ fontSize: 7, 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>
|
<Text style={{ fontSize: 24, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 16 }}>{section.title}</Text>
|
||||||
<AccentBar />
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{section.description && (
|
{section.description && (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<RichText style={{ fontSize: 11, color: textColor, lineHeight: 1.7 }} gap={10} color={boldColor}>
|
<RichText style={{ fontSize: 10, color: textColor, lineHeight: 1.7 }} gap={8} color={boldColor}>
|
||||||
{section.description}
|
{section.description}
|
||||||
</RichText>
|
</RichText>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Highlights — horizontal stat cards */}
|
{/* Highlights */}
|
||||||
{section.highlights && section.highlights.length > 0 && (
|
{section.highlights && section.highlights.length > 0 && (
|
||||||
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
|
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
|
||||||
{section.highlights.map((h, i) => (
|
{section.highlights.map((h, i) => (
|
||||||
<View key={i} style={{
|
<View key={i} style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
|
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
|
||||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||||
paddingVertical: 14, paddingHorizontal: 14,
|
paddingVertical: 12, paddingHorizontal: 12,
|
||||||
}}>
|
}}>
|
||||||
<Text style={{ fontSize: 20, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
|
<Text style={{ fontSize: 10, 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>
|
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pull quote */}
|
{/* Pull quote */}
|
||||||
{section.pullQuote && (
|
{section.pullQuote && (
|
||||||
<View style={{
|
<View style={{
|
||||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||||
paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
|
paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
|
||||||
}}>
|
}}>
|
||||||
<Text style={{ fontSize: 14, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
|
<Text style={{ fontSize: 12, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
|
||||||
„{section.pullQuote}"
|
„{section.pullQuote}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Items — 2-column grid with accent bars */}
|
{/* Items — 2-column grid */}
|
||||||
{section.items && section.items.length > 0 && (
|
{section.items && section.items.length > 0 && (
|
||||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
||||||
{section.items.map((item, i) => (
|
{section.items.map((item, i) => (
|
||||||
<View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
|
<View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
|
||||||
<View style={{ width: 20, height: 2, backgroundColor: C.green, marginBottom: 8 }} />
|
<Text style={{ fontSize: 9, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
|
||||||
<Text style={{ fontSize: 10, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
|
<View style={{ width: 20, height: 1.5, backgroundColor: dark ? 'rgba(255,255,255,0.2)' : C.gray300, marginBottom: 6 }} />
|
||||||
<RichText style={{ fontSize: 9, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
|
<RichText style={{ fontSize: 8.5, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
|
||||||
{item.description}
|
{item.description}
|
||||||
</RichText>
|
</RichText>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// About page (first info page, special layout with values grid)
|
// About page (first info page)
|
||||||
const AboutPage: React.FC<{
|
const AboutPage: React.FC<{
|
||||||
locale: 'en' | 'de';
|
locale: 'en' | 'de';
|
||||||
companyInfo: BrochureProps['companyInfo'];
|
companyInfo: BrochureProps['companyInfo'];
|
||||||
logoBlack?: string | Buffer;
|
logoBlack?: string | Buffer;
|
||||||
image?: string | Buffer;
|
image?: string | Buffer;
|
||||||
}> = ({ locale, companyInfo, logoBlack, image }) => {
|
messages?: Record<string, any>;
|
||||||
|
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||||
|
}> = ({ locale, companyInfo, logoBlack, image, messages, directorPhotos }) => {
|
||||||
const l = labels(locale);
|
const l = labels(locale);
|
||||||
|
|
||||||
|
// Image at top: 200pt tall (smaller to leave more room for content)
|
||||||
|
const IMG_TOP_H = 200;
|
||||||
|
const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP;
|
||||||
|
|
||||||
|
// Pull directors content from messages if available
|
||||||
|
const team = messages?.Team || {};
|
||||||
|
const michael = team.michael;
|
||||||
|
const klaus = team.klaus;
|
||||||
|
const legacy = team.legacy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||||
|
{/* Top-aligned image fading into white bottom */}
|
||||||
|
{imgValid(image) && (
|
||||||
|
<FadeImage
|
||||||
|
src={image!}
|
||||||
|
top={0} left={0} right={0}
|
||||||
|
width="100%" height={IMG_TOP_H}
|
||||||
|
fadeEdge="bottom" fadeSize={140}
|
||||||
|
bgColor={C.white}
|
||||||
|
opacity={0.92}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Header logo={logoBlack} right="KLZ Cables" />
|
<Header logo={logoBlack} right="KLZ Cables" />
|
||||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||||
|
|
||||||
{/* Full-width image at top */}
|
{/* Content pushed below the fading image */}
|
||||||
{imgValid(image) && (
|
<View style={{ paddingTop: bodyTopWithImg }}>
|
||||||
<View style={{ height: 220, marginBottom: 28, marginHorizontal: -MARGIN }}>
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
|
||||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<Text style={{ fontSize: 22, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 6 }}>KLZ Cables</Text>
|
||||||
|
<AccentBar />
|
||||||
|
|
||||||
|
<RichText style={{ fontSize: 10, color: C.gray900, lineHeight: 1.8 }} gap={8}>
|
||||||
|
{companyInfo.tagline}
|
||||||
|
</RichText>
|
||||||
|
|
||||||
|
{/* Company mission — makes immediately clear what KLZ does */}
|
||||||
|
<View style={{ marginTop: 12, marginBottom: 8 }}>
|
||||||
|
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }} gap={6}>
|
||||||
|
{locale === 'de'
|
||||||
|
? 'KLZ Cables ist Ihr Spezialist für Energiekabel von 1 kV bis 220 kV. Wir beliefern Energieversorger, Wind- und Solarparks sowie die Industrie mit VDE-geprüften Kabeln – von der Niederspannung über die Mittelspannung bis zur Hochspannung. Mit einem europaweiten Netzwerk und jahrzehntelanger Erfahrung sorgen wir für zuverlässige Kabelinfrastruktur.'
|
||||||
|
: 'KLZ Cables is your specialist for power cables from 1 kV to 220 kV. We supply energy providers, wind and solar parks, and industry with VDE-certified cables – from low voltage through medium voltage to high voltage. With a Europe-wide network and decades of experience, we ensure reliable cable infrastructure.'
|
||||||
|
}
|
||||||
|
</RichText>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
|
{/* Directors — two-column */}
|
||||||
<Text style={{ fontSize: 32, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>KLZ Cables</Text>
|
{(michael || klaus) && (
|
||||||
<AccentBar />
|
<View style={{ marginTop: 20 }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>
|
||||||
<RichText style={{ fontSize: 13, color: C.gray900, lineHeight: 1.8 }} gap={12}>
|
{locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'}
|
||||||
{companyInfo.tagline}
|
</Text>
|
||||||
</RichText>
|
<View style={{ flexDirection: 'row', gap: 20 }}>
|
||||||
|
{[{ data: michael, photo: directorPhotos?.michael }, { data: klaus, photo: directorPhotos?.klaus }].filter(p => p.data).map((p, i) => (
|
||||||
{/* Values grid */}
|
<View key={i} style={{ flex: 1 }}>
|
||||||
<View style={{ marginTop: 32 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 16 }}>{l.values}</Text>
|
{p.photo && (
|
||||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
<Image src={p.photo} style={{ width: 32, height: 32, borderRadius: 16 }} />
|
||||||
{companyInfo.values.map((v, i) => (
|
)}
|
||||||
<View key={i} style={{ width: '46%', marginBottom: 8 }}>
|
<View>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep, marginBottom: 1 }}>{p.data.name}</Text>
|
||||||
<View style={{ width: 28, height: 28, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 0.8 }}>{p.data.role}</Text>
|
||||||
<Text style={{ fontSize: 12, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.6, marginBottom: 6 }}>{p.data.description}</Text>
|
||||||
|
{p.data.quote && (
|
||||||
|
<View style={{ borderLeftWidth: 2, borderLeftColor: C.green, borderLeftStyle: 'solid', paddingLeft: 8 }}>
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep, fontStyle: 'italic', lineHeight: 1.5 }}>
|
||||||
|
„{p.data.quote}“
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Values grid */}
|
||||||
|
<View style={{ marginTop: 20 }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>{l.values}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 16 }}>
|
||||||
|
{companyInfo.values.map((v, i) => (
|
||||||
|
<View key={i} style={{ width: '46%', marginBottom: 4 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<View style={{ width: 20, height: 20, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{v.title}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 8, color: C.gray600, lineHeight: 1.5, paddingLeft: 28 }}>{v.description}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
@@ -364,53 +538,52 @@ const TocPage: React.FC<{
|
|||||||
locale: 'en' | 'de';
|
locale: 'en' | 'de';
|
||||||
logoBlack?: string | Buffer;
|
logoBlack?: string | Buffer;
|
||||||
productStartPage: number;
|
productStartPage: number;
|
||||||
image?: string | Buffer;
|
}> = ({ products, locale, logoBlack, productStartPage }) => {
|
||||||
}> = ({ products, locale, logoBlack, productStartPage, image }) => {
|
|
||||||
const l = labels(locale);
|
const l = labels(locale);
|
||||||
|
|
||||||
const grouped = new Map<string, Array<{ product: BrochureProduct; pageNum: number }>>();
|
// Group products by their first category
|
||||||
let idx = 0;
|
const categories: Array<{ name: string; products: Array<BrochureProduct & { startingPage: number }> }> = [];
|
||||||
|
let currentPageNum = productStartPage;
|
||||||
for (const p of products) {
|
for (const p of products) {
|
||||||
const cat = p.categories[0]?.name || 'Other';
|
const catName = p.categories[0]?.name || l.other;
|
||||||
if (!grouped.has(cat)) grouped.set(cat, []);
|
let category = categories.find(c => c.name === catName);
|
||||||
grouped.get(cat)!.push({ product: p, pageNum: productStartPage + idx });
|
if (!category) {
|
||||||
idx++;
|
category = { name: catName, products: [] };
|
||||||
|
categories.push(category);
|
||||||
|
}
|
||||||
|
category.products.push({ ...p, startingPage: currentPageNum });
|
||||||
|
currentPageNum++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||||
<Header logo={logoBlack} right={l.overview} />
|
<Header logo={logoBlack} right={l.overview} />
|
||||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||||
|
|
||||||
{/* Image strip */}
|
<View style={{ paddingTop: BODY_TOP + 40 }}>
|
||||||
{imgValid(image) && (
|
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 24 }}>
|
||||||
<View style={{ height: 140, marginBottom: 28, marginHorizontal: -MARGIN }}>
|
{l.catalog}
|
||||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
</Text>
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.catalog}</Text>
|
{categories.map((cat, i) => (
|
||||||
<Text style={{ fontSize: 28, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>{l.toc}</Text>
|
<View key={i} style={{ marginBottom: 16 }} minPresenceAhead={40}>
|
||||||
<AccentBar />
|
{cat.name && (
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||||
{Array.from(grouped.entries()).map(([cat, items]) => (
|
{cat.name}
|
||||||
<View key={cat} style={{ marginBottom: 16 }}>
|
</Text>
|
||||||
<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 style={{ flexDirection: 'column', gap: 6 }}>
|
||||||
</View>
|
{cat.products.map((p, j) => (
|
||||||
{items.map((item, i) => (
|
<View key={j} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||||
<View key={i} style={{
|
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{p.name}</Text>
|
||||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
<View style={{ flex: 1, borderBottomWidth: 1, borderBottomColor: C.gray200, borderBottomStyle: 'dotted', marginHorizontal: 8, marginBottom: 3 }} />
|
||||||
paddingVertical: 5,
|
<Text style={{ fontSize: 9, color: C.gray600 }}>{(p.startingPage || 0).toString().padStart(2, '0')}</Text>
|
||||||
borderBottomWidth: i < items.length - 1 ? 0.5 : 0,
|
</View>
|
||||||
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>
|
||||||
))}
|
</View>
|
||||||
</View>
|
))}
|
||||||
))}
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -425,82 +598,45 @@ const ProductPage: React.FC<{
|
|||||||
logoBlack?: string | Buffer;
|
logoBlack?: string | Buffer;
|
||||||
}> = ({ product, locale, logoBlack }) => {
|
}> = ({ product, locale, logoBlack }) => {
|
||||||
const l = labels(locale);
|
const l = labels(locale);
|
||||||
const desc = strip(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||||
<Header logo={logoBlack} right={l.overview} />
|
<Header logo={logoBlack} right="KLZ Cables" />
|
||||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||||
|
|
||||||
{/* Category + Name */}
|
{/* Product image block reduced strictly to 110pt high */}
|
||||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
{product.featuredImage && (
|
||||||
{product.categories.map(c => c.name).join(' · ')}
|
<View style={{ height: 110, marginBottom: 20, marginHorizontal: -MARGIN }}>
|
||||||
</Text>
|
<Image src={product.featuredImage as unknown as Buffer} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||||
<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>
|
||||||
<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 */}
|
{/* Labels & Name */}
|
||||||
|
{product.categories.length > 0 && (
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||||
|
{product.categories.map(c => c.name).join(' • ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 16 }}>{product.name}</Text>
|
||||||
|
|
||||||
|
{/* Description — full width */}
|
||||||
|
{product.descriptionHtml && (
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 6 }}>{l.application}</Text>
|
||||||
|
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.6 }} gap={6}>
|
||||||
|
{product.descriptionHtml}
|
||||||
|
</RichText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Technical Data — full-width striped table */}
|
||||||
{product.attributes && product.attributes.length > 0 && (
|
{product.attributes && product.attributes.length > 0 && (
|
||||||
<View>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 10 }}>{l.specs}</Text>
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.specs}</Text>
|
||||||
|
|
||||||
{/* Clean table header */}
|
{/* Table header */}
|
||||||
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 2 }}>
|
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 5, paddingHorizontal: 10, marginBottom: 2 }}>
|
||||||
<View style={{ width: '55%' }}>
|
<View style={{ width: '50%' }}>
|
||||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.property}</Text>
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.property}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@@ -511,21 +647,49 @@ const ProductPage: React.FC<{
|
|||||||
{product.attributes.map((attr, i) => (
|
{product.attributes.map((attr, i) => (
|
||||||
<View key={i} style={{
|
<View key={i} style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingVertical: 5,
|
paddingVertical: 5, paddingHorizontal: 10,
|
||||||
backgroundColor: i % 2 === 0 ? C.white : C.offWhite,
|
backgroundColor: i % 2 === 0 ? C.white : C.offWhite,
|
||||||
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
|
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
|
||||||
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||||
}}>
|
}}>
|
||||||
<View style={{ width: '55%', paddingRight: 8 }}>
|
<View style={{ width: '50%', paddingRight: 12 }}>
|
||||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
|
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={{ fontSize: 9, color: C.gray900 }}>{attr.options.join(', ')}</Text>
|
<Text style={{ fontSize: 8, color: C.gray900 }}>{attr.options.join(', ')}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* QR Codes — horizontal row at bottom */}
|
||||||
|
{(product.qrWebsite || product.qrDatasheet) && (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 24, marginTop: 8 }}>
|
||||||
|
{product.qrWebsite && (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 2 }}>
|
||||||
|
<Image src={product.qrWebsite} style={{ width: 36, height: 36 }} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 1 }}>{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: 8 }}>
|
||||||
|
<View style={{ borderWidth: 1, borderColor: C.gray200, borderStyle: 'solid', padding: 2 }}>
|
||||||
|
<Image src={product.qrDatasheet} style={{ width: 36, height: 36 }} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 7, fontWeight: 700, color: C.navyDeep, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 1 }}>{l.qrPdf}</Text>
|
||||||
|
<Text style={{ fontSize: 7, color: C.gray400 }}>{locale === 'de' ? 'Datenblatt' : 'Datasheet'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -584,19 +748,26 @@ const BackCover: React.FC<{
|
|||||||
|
|
||||||
export const PDFBrochure: React.FC<BrochureProps> = ({
|
export const PDFBrochure: React.FC<BrochureProps> = ({
|
||||||
products, locale, companyInfo, introContent,
|
products, locale, companyInfo, introContent,
|
||||||
marketingSections, logoBlack, logoWhite, galleryImages,
|
marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
|
||||||
}) => {
|
}) => {
|
||||||
// Calculate actual page numbers
|
|
||||||
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
|
// Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
|
||||||
const numInfoPages = 1 + (marketingSections?.length || 0); // About + sections
|
const numInfoPages = 1 + (marketingSections?.length || 0);
|
||||||
const productStartPage = 1 + numInfoPages + 1; // Cover + info pages + TOC
|
const productStartPage = 1 + numInfoPages + 1;
|
||||||
|
|
||||||
// Assign images to sections: dark sections get indices 2,4; light get 3
|
// Image assignment — each page gets a UNIQUE image, never repeating
|
||||||
|
// galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover
|
||||||
|
// TOC intentionally gets NO image (clean list page)
|
||||||
|
const totalGallery = galleryImages?.length || 0;
|
||||||
|
const backCoverImgIdx = totalGallery - 1;
|
||||||
|
|
||||||
|
// Section themes: alternate light/dark
|
||||||
const sectionThemes: Array<'light' | 'dark'> = [];
|
const sectionThemes: Array<'light' | 'dark'> = [];
|
||||||
|
// imagePosition: alternate between top and bottom-half for variety
|
||||||
|
const imagePositions: Array<'top' | 'bottom-half'> = [];
|
||||||
if (marketingSections) {
|
if (marketingSections) {
|
||||||
for (let i = 0; i < marketingSections.length; i++) {
|
for (let i = 0; i < marketingSections.length; i++) {
|
||||||
// Alternate: light, dark, light, dark, light, dark
|
|
||||||
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
|
sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
|
||||||
|
imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,30 +775,32 @@ export const PDFBrochure: React.FC<BrochureProps> = ({
|
|||||||
<Document>
|
<Document>
|
||||||
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
|
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
|
||||||
|
|
||||||
{/* About page with image index 1 */}
|
{/* About page — image[1] */}
|
||||||
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} />
|
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} messages={messages} directorPhotos={directorPhotos} />
|
||||||
|
|
||||||
{/* Each marketing section gets its own page */}
|
{/* Info sections — images[2..] each unique, alternating top/bottom and light/dark */}
|
||||||
{marketingSections?.map((section, i) => (
|
{marketingSections?.map((section, i) => (
|
||||||
<InfoPage
|
<InfoPage
|
||||||
key={`info-${i}`}
|
key={`info-${i}`}
|
||||||
section={section}
|
section={section}
|
||||||
image={galleryImages?.[i + 2]}
|
image={galleryImages?.[i + 2]}
|
||||||
logoBlack={logoBlack}
|
logoBlack={logoBlack}
|
||||||
|
logoWhite={logoWhite}
|
||||||
dark={sectionThemes[i] === 'dark'}
|
dark={sectionThemes[i] === 'dark'}
|
||||||
|
imagePosition={imagePositions[i]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* TOC */}
|
{/* TOC — no decorative image, clean list */}
|
||||||
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} image={galleryImages?.[5]} />
|
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} />
|
||||||
|
|
||||||
{/* Products — each on its own page */}
|
{/* Products — each on its own page */}
|
||||||
{products.map(p => (
|
{products.map(p => (
|
||||||
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
|
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Back cover */}
|
{/* Back cover — last gallery image */}
|
||||||
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[6]} />
|
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[backCoverImgIdx]} />
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+97
-94
@@ -1,13 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||||
Document,
|
|
||||||
Page,
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Image,
|
|
||||||
StyleSheet,
|
|
||||||
Font,
|
|
||||||
} from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
// Register fonts (using system fonts for now, can be customized)
|
// Register fonts (using system fonts for now, can be customized)
|
||||||
Font.register({
|
Font.register({
|
||||||
@@ -18,27 +10,43 @@ Font.register({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
// ─── Brand Tokens (matching brochure) ────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
navy: '#001a4d',
|
||||||
|
navyDeep: '#000d26',
|
||||||
|
green: '#4da612',
|
||||||
|
greenLight: '#e8f5d8',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
offWhite: '#f8f9fa',
|
||||||
|
gray100: '#f3f4f6',
|
||||||
|
gray200: '#e5e7eb',
|
||||||
|
gray300: '#d1d5db',
|
||||||
|
gray400: '#9ca3af',
|
||||||
|
gray600: '#4b5563',
|
||||||
|
gray900: '#111827',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MARGIN = 56;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
color: '#111827', // Text Primary
|
color: C.gray900,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: C.white,
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
paddingBottom: 100,
|
paddingBottom: 80,
|
||||||
fontFamily: 'Helvetica',
|
fontFamily: 'Helvetica',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Hero-style header
|
// Hero-style header
|
||||||
hero: {
|
hero: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: C.white,
|
||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
paddingHorizontal: 72,
|
paddingHorizontal: MARGIN,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderBottomWidth: 0,
|
borderBottomWidth: 0,
|
||||||
borderBottomColor: '#e5e7eb',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
@@ -49,17 +57,17 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
logoText: {
|
logoText: {
|
||||||
fontSize: 24,
|
fontSize: 22,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
letterSpacing: 1,
|
letterSpacing: 2,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
},
|
},
|
||||||
|
|
||||||
docTitle: {
|
docTitle: {
|
||||||
fontSize: 10,
|
fontSize: 8,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#001a4d',
|
color: C.green,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
},
|
},
|
||||||
@@ -78,10 +86,10 @@ const styles = StyleSheet.create({
|
|||||||
height: 120,
|
height: 120,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: 8,
|
borderRadius: 4,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e7eb',
|
borderColor: C.gray200,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: C.white,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -93,7 +101,7 @@ const styles = StyleSheet.create({
|
|||||||
productName: {
|
productName: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
@@ -101,7 +109,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
productMeta: {
|
productMeta: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: '#4b5563',
|
color: C.gray600,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
@@ -115,13 +123,13 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
noImage: {
|
noImage: {
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
color: '#9ca3af',
|
color: C.gray400,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Content Area
|
// Content Area
|
||||||
content: {
|
content: {
|
||||||
paddingHorizontal: 72,
|
paddingHorizontal: MARGIN,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Content sections
|
// Content sections
|
||||||
@@ -130,40 +138,40 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 14,
|
fontSize: 8,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26', // Primary Dark
|
color: C.green,
|
||||||
marginBottom: 8,
|
marginBottom: 6,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: -0.2,
|
letterSpacing: 1.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
sectionAccent: {
|
sectionAccent: {
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 3,
|
height: 2,
|
||||||
backgroundColor: '#82ed20', // Accent Green
|
backgroundColor: C.green,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
description: {
|
description: {
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
color: '#4b5563', // Text Secondary
|
color: C.gray600,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Technical data table
|
// Technical data table
|
||||||
specsTable: {
|
specsTable: {
|
||||||
marginTop: 8,
|
marginTop: 4,
|
||||||
border: '1px solid #e5e7eb',
|
borderWidth: 0,
|
||||||
borderRadius: 8,
|
borderRadius: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableRow: {
|
specsTableRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 0.5,
|
||||||
borderBottomColor: '#e5e7eb',
|
borderBottomColor: C.gray200,
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableRowLast: {
|
specsTableRowLast: {
|
||||||
@@ -172,83 +180,85 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
specsTableLabelCell: {
|
specsTableLabelCell: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 4,
|
paddingVertical: 5,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
backgroundColor: '#f8f9fa',
|
backgroundColor: C.offWhite,
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 0.5,
|
||||||
borderRightColor: '#e5e7eb',
|
borderRightColor: C.gray200,
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableValueCell: {
|
specsTableValueCell: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 4,
|
paddingVertical: 5,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableLabelText: {
|
specsTableLabelText: {
|
||||||
fontSize: 9,
|
fontSize: 8,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
specsTableValueText: {
|
specsTableValueText: {
|
||||||
fontSize: 10,
|
fontSize: 9,
|
||||||
color: '#111827',
|
color: C.gray900,
|
||||||
fontWeight: 500,
|
fontWeight: 400,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
categories: {
|
categories: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
|
|
||||||
categoryTag: {
|
categoryTag: {
|
||||||
backgroundColor: '#f8f9fa',
|
backgroundColor: C.offWhite,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 6,
|
paddingVertical: 4,
|
||||||
border: '1px solid #e5e7eb',
|
borderWidth: 0.5,
|
||||||
borderRadius: 100,
|
borderColor: C.gray200,
|
||||||
|
borderRadius: 3,
|
||||||
},
|
},
|
||||||
|
|
||||||
categoryText: {
|
categoryText: {
|
||||||
fontSize: 8,
|
fontSize: 7,
|
||||||
color: '#4b5563',
|
color: C.gray600,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Footer
|
// Footer — matches brochure style
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 40,
|
bottom: 28,
|
||||||
left: 72,
|
left: MARGIN,
|
||||||
right: 72,
|
right: MARGIN,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: 24,
|
paddingTop: 12,
|
||||||
borderTop: '1px solid #e5e7eb',
|
borderTopWidth: 2,
|
||||||
|
borderTopColor: C.green,
|
||||||
},
|
},
|
||||||
|
|
||||||
footerText: {
|
footerText: {
|
||||||
fontSize: 8,
|
fontSize: 7,
|
||||||
color: '#9ca3af',
|
color: C.gray400,
|
||||||
fontWeight: 500,
|
fontWeight: 400,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 0.8,
|
||||||
},
|
},
|
||||||
|
|
||||||
footerBrand: {
|
footerBrand: {
|
||||||
fontSize: 10,
|
fontSize: 9,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1.5,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,10 +312,7 @@ const getLabels = (locale: 'en' | 'de') => {
|
|||||||
return labels[locale];
|
return labels[locale];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||||
product,
|
|
||||||
locale,
|
|
||||||
}) => {
|
|
||||||
const labels = getLabels(locale);
|
const labels = getLabels(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -317,9 +324,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<View>
|
<View>
|
||||||
<Text style={styles.logoText}>KLZ</Text>
|
<Text style={styles.logoText}>KLZ</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.docTitle}>
|
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||||
{labels.productDatasheet}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.productRow}>
|
<View style={styles.productRow}>
|
||||||
@@ -328,7 +333,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<View style={styles.categories}>
|
<View style={styles.categories}>
|
||||||
{product.categories.map((cat, index) => (
|
{product.categories.map((cat, index) => (
|
||||||
<Text key={index} style={styles.productMeta}>
|
<Text key={index} style={styles.productMeta}>
|
||||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
{cat.name}
|
||||||
|
{index < product.categories.length - 1 ? ' • ' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -337,12 +343,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.productImageCol}>
|
<View style={styles.productImageCol}>
|
||||||
{product.featuredImage ? (
|
{product.featuredImage ? (
|
||||||
<Image
|
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||||
src={product.featuredImage}
|
|
||||||
style={styles.heroImage}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -356,7 +358,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||||
<View style={styles.sectionAccent} />
|
<View style={styles.sectionAccent} />
|
||||||
<Text style={styles.description}>
|
<Text style={styles.description}>
|
||||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
{stripHtml(
|
||||||
|
product.applicationHtml ||
|
||||||
|
product.shortDescriptionHtml ||
|
||||||
|
product.descriptionHtml,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -372,17 +378,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
key={index}
|
key={index}
|
||||||
style={[
|
style={[
|
||||||
styles.specsTableRow,
|
styles.specsTableRow,
|
||||||
index === product.attributes.length - 1 &&
|
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||||
styles.specsTableRowLast,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.specsTableLabelCell}>
|
<View style={styles.specsTableLabelCell}>
|
||||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.specsTableValueCell}>
|
<View style={styles.specsTableValueCell}>
|
||||||
<Text style={styles.specsTableValueText}>
|
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||||
{attr.options.join(', ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Strict-Transport-Security',
|
key: 'Strict-Transport-Security',
|
||||||
value: 'max-age=63072000; includeSubDomains; preload',
|
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.0.2",
|
"version": "2.2.11",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,74 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
const products: any[] = [];
|
||||||
|
|
||||||
|
for (const locale of ['en', 'de'] as const) {
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: 'products',
|
||||||
|
locale: locale as any,
|
||||||
|
pagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const doc of result.docs) {
|
||||||
|
if (!doc.title || !doc.slug) continue;
|
||||||
|
|
||||||
|
const images: string[] = [];
|
||||||
|
if (doc.featuredImage) {
|
||||||
|
const url =
|
||||||
|
typeof doc.featuredImage === 'string'
|
||||||
|
? doc.featuredImage
|
||||||
|
: (doc.featuredImage as any).url;
|
||||||
|
if (url) images.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = Array.isArray(doc.categories)
|
||||||
|
? doc.categories
|
||||||
|
.map((c: any) => ({ name: String(c.category || c) }))
|
||||||
|
.filter((c: any) => c.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const attributes: any[] = [];
|
||||||
|
if (Array.isArray((doc as any).content?.root?.children)) {
|
||||||
|
const ptBlock = (doc as any).content.root.children.find(
|
||||||
|
(n: any) => n.type === 'block' && n.fields?.blockType === 'productTabs',
|
||||||
|
);
|
||||||
|
if (ptBlock?.fields?.technicalItems) {
|
||||||
|
for (const item of ptBlock.fields.technicalItems) {
|
||||||
|
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
|
||||||
|
if (label && item.value) attributes.push({ name: label, options: [item.value] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
products.push({
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.title,
|
||||||
|
shortDescriptionHtml: (doc as any).shortDescription || '',
|
||||||
|
descriptionHtml: '',
|
||||||
|
images,
|
||||||
|
featuredImage: images[0] || null,
|
||||||
|
sku: doc.slug,
|
||||||
|
slug: doc.slug,
|
||||||
|
translationKey: doc.slug,
|
||||||
|
locale,
|
||||||
|
categories,
|
||||||
|
attributes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(process.cwd(), 'data/processed');
|
||||||
|
if (!fs.existsSync(outDir)) {
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(outDir, 'products.json'), JSON.stringify(products, null, 2));
|
||||||
|
console.log(`Exported ${products.length} products to data/processed/products.json`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+103
-95
@@ -254,91 +254,63 @@ async function loadMarketingSections(locale: 'en' | 'de'): Promise<BrochureProps
|
|||||||
|
|
||||||
const sections: NonNullable<BrochureProps['marketingSections']> = [];
|
const sections: NonNullable<BrochureProps['marketingSections']> = [];
|
||||||
|
|
||||||
// 1. What we do — short label, long subtitle becomes the description
|
// ── 1. Was wir tun + Warum wir — MERGED into one compact section ──
|
||||||
if (messages.Home?.whatWeDo) {
|
{
|
||||||
const label = locale === 'de' ? 'Unser Angebot' : 'Our Services';
|
const allItems: Array<{ title: string; description: string }> = [];
|
||||||
sections.push({
|
|
||||||
title: messages.Home.whatWeDo.title,
|
|
||||||
subtitle: label,
|
|
||||||
description: messages.Home.whatWeDo.subtitle,
|
|
||||||
items: messages.Home.whatWeDo.items,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Our Legacy — with stats highlight
|
// WhatWeDo items — truncated to 1 sentence each
|
||||||
if (messages.Team?.legacy) {
|
if (messages.Home?.whatWeDo?.items) {
|
||||||
const label = locale === 'de' ? 'Unsere Geschichte' : 'Our Story';
|
for (const item of messages.Home.whatWeDo.items) {
|
||||||
sections.push({
|
allItems.push({
|
||||||
title: messages.Team.legacy.title,
|
title: item.title.split('.')[0], // short title
|
||||||
subtitle: label,
|
description: item.description.split('.')[0] + '.',
|
||||||
description: `${messages.Team.legacy.p1}\n\n${messages.Team.legacy.p2}`,
|
});
|
||||||
highlights: [
|
}
|
||||||
{ value: messages.Team.legacy.expertise || 'Expertise', label: messages.Team.legacy.expertiseDesc || '' },
|
|
||||||
{ value: messages.Team.legacy.network || 'Netzwerk', label: messages.Team.legacy.networkDesc || '' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Experience stats section
|
|
||||||
if (messages.Home?.experience) {
|
|
||||||
const label = locale === 'de' ? 'Erfahrung' : 'Experience';
|
|
||||||
sections.push({
|
|
||||||
title: messages.Home.experience.title,
|
|
||||||
subtitle: label,
|
|
||||||
description: `${messages.Home.experience.p1 || ''}\n\n${messages.Home.experience.p2 || ''}`.trim(),
|
|
||||||
highlights: [
|
|
||||||
{ value: messages.Home.experience.certifiedQuality || (locale === 'de' ? 'Zertifizierte Qualität' : 'Certified Quality'), label: messages.Home.experience.vdeApproved || '' },
|
|
||||||
{ value: messages.Home.experience.fullSpectrum || (locale === 'de' ? 'Volles Spektrum' : 'Full Spectrum'), label: messages.Home.experience.solutionsRange || '' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Why choose us
|
|
||||||
if (messages.Home?.whyChooseUs) {
|
|
||||||
const label = locale === 'de' ? 'Warum KLZ' : 'Why KLZ';
|
|
||||||
sections.push({
|
|
||||||
title: messages.Home.whyChooseUs.title,
|
|
||||||
subtitle: label,
|
|
||||||
description: messages.Home.whyChooseUs.subtitle || '',
|
|
||||||
items: messages.Home.whyChooseUs.items,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Team intro + quotes as pull quotes
|
|
||||||
if (messages.Team?.klaus || messages.Team?.michael) {
|
|
||||||
const label = locale === 'de' ? 'Die Geschäftsführer' : 'The Directors';
|
|
||||||
const title = messages.Home?.meetTheTeam?.title || (locale === 'de' ? 'Das Team' : 'The Team');
|
|
||||||
const teamItems: Array<{ title: string; description: string }> = [];
|
|
||||||
if (messages.Team?.klaus) {
|
|
||||||
teamItems.push({
|
|
||||||
title: `${messages.Team.klaus.name} – ${messages.Team.klaus.role}`,
|
|
||||||
description: messages.Team.klaus.description,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (messages.Team?.michael) {
|
// WhyChooseUs items — truncated to 1 sentence each
|
||||||
teamItems.push({
|
if (messages.Home?.whyChooseUs?.items) {
|
||||||
title: `${messages.Team.michael.name} – ${messages.Team.michael.role}`,
|
for (const item of messages.Home.whyChooseUs.items) {
|
||||||
description: messages.Team.michael.description,
|
allItems.push({
|
||||||
});
|
title: item.title,
|
||||||
|
description: item.description.split('.')[0] + '.',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const desc = messages.Home?.meetTheTeam?.description || '';
|
|
||||||
sections.push({
|
sections.push({
|
||||||
title,
|
title: messages.Home?.whatWeDo?.title || (locale === 'de' ? 'Was wir tun' : 'What We Do'),
|
||||||
subtitle: label,
|
subtitle: locale === 'de' ? 'Leistungen & Stärken' : 'Services & Strengths',
|
||||||
|
description: messages.Home?.whatWeDo?.subtitle || '',
|
||||||
|
items: allItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Experience & Quality — merge Legacy + Experience highlights ──
|
||||||
|
{
|
||||||
|
const legacy = messages.Team?.legacy;
|
||||||
|
const experience = messages.Home?.experience;
|
||||||
|
const highlights: Array<{ value: string; label: string }> = [];
|
||||||
|
|
||||||
|
if (legacy) {
|
||||||
|
highlights.push(
|
||||||
|
{ value: legacy.expertise || 'Expertise', label: legacy.expertiseDesc || '' },
|
||||||
|
{ value: legacy.network || (locale === 'de' ? 'Netzwerk' : 'Network'), label: legacy.networkDesc || '' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (experience) {
|
||||||
|
highlights.push(
|
||||||
|
{ value: experience.certifiedQuality || (locale === 'de' ? 'Zertifiziert' : 'Certified'), label: experience.vdeApproved || '' },
|
||||||
|
{ value: experience.fullSpectrum || (locale === 'de' ? 'Volles Spektrum' : 'Full Spectrum'), label: experience.solutionsRange || '' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const desc = legacy?.p1 || '';
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
title: legacy?.title || (locale === 'de' ? 'Erfahrung & Qualität' : 'Experience & Quality'),
|
||||||
|
subtitle: locale === 'de' ? 'Unser Erbe' : 'Our Heritage',
|
||||||
description: desc,
|
description: desc,
|
||||||
items: teamItems,
|
highlights,
|
||||||
pullQuote: messages.Team.klaus?.quote || messages.Team.michael?.quote || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Our Values (Manifesto)
|
|
||||||
if (messages.Team?.manifesto) {
|
|
||||||
const label = locale === 'de' ? 'Grundprinzipien' : 'Core Principles';
|
|
||||||
sections.push({
|
|
||||||
title: messages.Team.manifesto.title,
|
|
||||||
subtitle: label,
|
|
||||||
description: messages.Team.manifesto.tagline,
|
|
||||||
items: messages.Team.manifesto.items,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,29 +367,35 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
console.log(`Logos: white=${!!logoWhite} black=${!!logoBlack}`);
|
console.log(`Logos: white=${!!logoWhite} black=${!!logoBlack}`);
|
||||||
|
|
||||||
// Load gallery images — 7 diverse images for different sections
|
// EXACT image mapping — 2 marketing sections now
|
||||||
const galleryPaths = [
|
// Index map: 0=Cover, 1=About, 2=WasWirTun(null), 3=Erfahrung(Legacy image), 4=BackCover
|
||||||
'uploads/2024/12/DSC07433-Large-600x400.webp', // 0: Cover
|
const galleryPaths: Array<string | null> = [
|
||||||
'uploads/2024/12/DSC07460-Large-600x400.webp', // 1: About section
|
'uploads/2024/12/large-rolls-of-wires-against-the-blue-sky-at-sunse-2023-11-27-05-20-33-utc-Large.webp', // 0: Cover (cable drums, no people)
|
||||||
'uploads/2025/01/technicians-inspecting-wind-turbines-in-a-green-en-2024-12-09-01-25-20-utc-scaled.webp', // 2: After "Was wir tun"
|
'uploads/2024/12/DSC07460-Large-600x400.webp', // 1: About section
|
||||||
'uploads/2025/01/power-grid-station-electrical-distribution-statio-2023-11-27-05-25-36-utc-scaled.webp', // 3: After Legacy
|
null, // 2: Was wir tun (NO IMAGE — text-heavy)
|
||||||
'uploads/2025/01/transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled.webp', // 4: After Experience
|
'uploads/2024/12/1694273920124-copy.webp', // 3: Erfahrung & Qualität
|
||||||
'uploads/2024/12/DSC07539-Large-600x400.webp', // 5: TOC page
|
'uploads/2024/12/DSC07433-Large-600x400.webp', // 4: Back cover
|
||||||
'uploads/2025/01/business-planning-hand-using-laptop-for-working-te-2024-11-01-21-25-44-utc-scaled.webp', // 6: Back cover
|
|
||||||
];
|
];
|
||||||
const galleryImages: (string | Buffer)[] = [];
|
|
||||||
|
const galleryImages: (string | Buffer | undefined)[] = [];
|
||||||
for (const gp of galleryPaths) {
|
for (const gp of galleryPaths) {
|
||||||
|
if (!gp) {
|
||||||
|
galleryImages.push(undefined);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const fullPath = path.join(process.cwd(), 'public', gp);
|
const fullPath = path.join(process.cwd(), 'public', gp);
|
||||||
if (fs.existsSync(fullPath)) {
|
if (fs.existsSync(fullPath)) {
|
||||||
try {
|
try {
|
||||||
const buf = await sharp(fullPath).png({ quality: 80 }).resize(600).toBuffer();
|
const buf = await sharp(fullPath).png({ quality: 80 }).resize(800).toBuffer();
|
||||||
galleryImages.push(buf);
|
galleryImages.push(buf);
|
||||||
} catch { /* skip */ }
|
} catch {
|
||||||
|
galleryImages.push(undefined);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
galleryImages.push(Buffer.alloc(0)); // placeholder to maintain index mapping
|
galleryImages.push(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`Gallery images loaded: ${galleryImages.filter(b => (b as Buffer).length > 0).length}`);
|
console.log(`Gallery images mapping complete. Succeeded bindings: ${galleryImages.filter(b => b !== undefined).length}`);
|
||||||
|
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
console.log(`\nGenerating ${locale.toUpperCase()} brochure...`);
|
console.log(`\nGenerating ${locale.toUpperCase()} brochure...`);
|
||||||
@@ -430,12 +408,42 @@ async function main(): Promise<void> {
|
|||||||
if (products.length === 0) continue;
|
if (products.length === 0) continue;
|
||||||
const companyInfo = getCompanyInfo(locale);
|
const companyInfo = getCompanyInfo(locale);
|
||||||
|
|
||||||
|
// Load messages for About page content (directors, legacy, etc.)
|
||||||
|
let messages: Record<string, any> | undefined;
|
||||||
|
try {
|
||||||
|
const messagesPath = path.join(process.cwd(), `messages/${locale}.json`);
|
||||||
|
messages = JSON.parse(fs.readFileSync(messagesPath, 'utf-8'));
|
||||||
|
} catch { /* messages are optional */ }
|
||||||
|
|
||||||
|
// Load director portrait photos and crop to circles
|
||||||
|
const directorPhotos: { michael?: Buffer; klaus?: Buffer } = {};
|
||||||
|
const portraitPaths = {
|
||||||
|
michael: path.join(process.cwd(), 'public/uploads/2024/12/DSC07768-Large.webp'),
|
||||||
|
klaus: path.join(process.cwd(), 'public/uploads/2024/12/DSC07963-Large.webp'),
|
||||||
|
};
|
||||||
|
const AVATAR_SIZE = 120; // px, will be rendered at 32pt in PDF
|
||||||
|
const circleMask = Buffer.from(
|
||||||
|
`<svg width="${AVATAR_SIZE}" height="${AVATAR_SIZE}"><circle cx="${AVATAR_SIZE / 2}" cy="${AVATAR_SIZE / 2}" r="${AVATAR_SIZE / 2}" fill="white"/></svg>`
|
||||||
|
);
|
||||||
|
for (const [key, photoPath] of Object.entries(portraitPaths)) {
|
||||||
|
if (fs.existsSync(photoPath)) {
|
||||||
|
try {
|
||||||
|
const cropped = await sharp(photoPath)
|
||||||
|
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'top' })
|
||||||
|
.composite([{ input: circleMask, blend: 'dest-in' }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
directorPhotos[key as 'michael' | 'klaus'] = cropped;
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Render the React-PDF brochure
|
// Render the React-PDF brochure
|
||||||
const buffer = await renderToBuffer(
|
const buffer = await renderToBuffer(
|
||||||
React.createElement(PDFBrochure, {
|
React.createElement(PDFBrochure, {
|
||||||
products, locale, companyInfo, introContent,
|
products, locale, companyInfo, introContent,
|
||||||
marketingSections, logoBlack, logoWhite, galleryImages,
|
marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
|
||||||
} as any) as any
|
} as any) as any
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '../payload.config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: { slug: { equals: 'start' } },
|
||||||
|
locale: 'de',
|
||||||
|
depth: 2,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(result.docs[0], null, 2));
|
||||||
|
}
|
||||||
|
main().catch(console.error);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import puppeteer, { Browser, Page } from 'puppeteer';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { parse } from 'url';
|
||||||
|
import next from 'next';
|
||||||
|
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
// The URL of the local Next.js server we are testing against.
|
||||||
|
// In CI, we expect the server to already be running at http://127.0.0.1:3000
|
||||||
|
const BASE_URL = process.env.TEST_URL || 'http://127.0.0.1:3000';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (browser) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AutoBrochureModal should open after 5 seconds and submit email', async () => {
|
||||||
|
// Set a long timeout for the test to prevent premature failure
|
||||||
|
page = await browser.newPage();
|
||||||
|
|
||||||
|
// Clear localStorage to ensure the auto-open logic fires
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload page to start the timer fresh
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'networkidle0' });
|
||||||
|
|
||||||
|
// Wait for auto-trigger (set to 5s in code, we wait up to 10s here)
|
||||||
|
const modal = await page.waitForSelector('div[role="dialog"]', { timeout: 10000 });
|
||||||
|
expect(modal).not.toBeNull();
|
||||||
|
|
||||||
|
// Ensure the email input is present inside the modal (using the placeholder to be sure)
|
||||||
|
const emailInput = await page.waitForSelector('input[name="email"]', { visible: true });
|
||||||
|
expect(emailInput).not.toBeNull();
|
||||||
|
|
||||||
|
// Fill the form matching BrochureCTA.tsx id=brochure-email
|
||||||
|
await page.type('input[name="email"]', 'test-brochure@mintel.me');
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for the success state UI to appear
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.body.innerText.includes('Successfully sent') ||
|
||||||
|
document.body.innerText.includes('Erfolgreich gesendet'),
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify localStorage was set correctly so it doesn't open again
|
||||||
|
const hasSeenModal = await page.evaluate(() => localStorage.getItem('klz_brochure_modal_seen'));
|
||||||
|
expect(hasSeenModal).toBe('true');
|
||||||
|
}, 30000);
|
||||||
Reference in New Issue
Block a user