Compare commits
5 Commits
3a61d01384
...
02be8e59b2
| 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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
28
components/AutoBrochureModal.tsx
Normal file
28
components/AutoBrochureModal.tsx
Normal file
@@ -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)} />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface BrochureDeliveryEmailProps {
|
||||||
|
_email: string;
|
||||||
|
brochureUrl: string;
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BrochureDeliveryEmail = ({
|
||||||
|
_email,
|
||||||
|
brochureUrl,
|
||||||
|
locale = 'en',
|
||||||
|
}: BrochureDeliveryEmailProps) => {
|
||||||
|
const t =
|
||||||
|
locale === 'de'
|
||||||
|
? {
|
||||||
|
subject: 'Ihr 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>
|
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
2480
data/processed/products.json
Normal file
2480
data/processed/products.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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.
BIN
public/datasheets/h1z2z2-k-de.pdf
Normal file
BIN
public/datasheets/h1z2z2-k-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/h1z2z2-k-en.pdf
Normal file
BIN
public/datasheets/h1z2z2-k-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2x2y-de.pdf
Normal file
BIN
public/datasheets/n2x2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2x2y-en.pdf
Normal file
BIN
public/datasheets/n2x2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfk2y-de.pdf
Normal file
BIN
public/datasheets/n2xfk2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfk2y-en.pdf
Normal file
BIN
public/datasheets/n2xfk2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfkld2y-de.pdf
Normal file
BIN
public/datasheets/n2xfkld2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfkld2y-en.pdf
Normal file
BIN
public/datasheets/n2xfkld2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xs2y-de.pdf
Normal file
BIN
public/datasheets/n2xs2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xs2y-en.pdf
Normal file
BIN
public/datasheets/n2xs2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsf2y-de.pdf
Normal file
BIN
public/datasheets/n2xsf2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsf2y-en.pdf
Normal file
BIN
public/datasheets/n2xsf2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-hv-de.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-hv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-hv-en.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-hv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-mv-de.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-mv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-mv-en.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-mv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsy-de.pdf
Normal file
BIN
public/datasheets/n2xsy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsy-en.pdf
Normal file
BIN
public/datasheets/n2xsy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xy-de.pdf
Normal file
BIN
public/datasheets/n2xy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xy-en.pdf
Normal file
BIN
public/datasheets/n2xy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2x2y-de.pdf
Normal file
BIN
public/datasheets/na2x2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2x2y-en.pdf
Normal file
BIN
public/datasheets/na2x2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfk2y-de.pdf
Normal file
BIN
public/datasheets/na2xfk2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfk2y-en.pdf
Normal file
BIN
public/datasheets/na2xfk2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfkld2y-de.pdf
Normal file
BIN
public/datasheets/na2xfkld2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfkld2y-en.pdf
Normal file
BIN
public/datasheets/na2xfkld2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xs2y-de.pdf
Normal file
BIN
public/datasheets/na2xs2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xs2y-en.pdf
Normal file
BIN
public/datasheets/na2xs2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsf2y-de.pdf
Normal file
BIN
public/datasheets/na2xsf2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsf2y-en.pdf
Normal file
BIN
public/datasheets/na2xsf2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-hv-de.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-hv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-hv-en.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-hv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-mv-de.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-mv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-mv-en.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-mv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsy-de.pdf
Normal file
BIN
public/datasheets/na2xsy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsy-en.pdf
Normal file
BIN
public/datasheets/na2xsy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xy-de.pdf
Normal file
BIN
public/datasheets/na2xy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xy-en.pdf
Normal file
BIN
public/datasheets/na2xy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nay2y-de.pdf
Normal file
BIN
public/datasheets/nay2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nay2y-en.pdf
Normal file
BIN
public/datasheets/nay2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/naycwy-de.pdf
Normal file
BIN
public/datasheets/naycwy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/naycwy-en.pdf
Normal file
BIN
public/datasheets/naycwy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nayy-de.pdf
Normal file
BIN
public/datasheets/nayy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nayy-en.pdf
Normal file
BIN
public/datasheets/nayy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/ny2y-de.pdf
Normal file
BIN
public/datasheets/ny2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/ny2y-en.pdf
Normal file
BIN
public/datasheets/ny2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nycwy-de.pdf
Normal file
BIN
public/datasheets/nycwy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nycwy-en.pdf
Normal file
BIN
public/datasheets/nycwy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nyy-de.pdf
Normal file
BIN
public/datasheets/nyy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nyy-en.pdf
Normal file
BIN
public/datasheets/nyy-en.pdf
Normal file
Binary file not shown.
74
scripts/export-legacy-products-json.ts
Normal file
74
scripts/export-legacy-products-json.ts
Normal file
@@ -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();
|
||||||
@@ -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
14
scripts/inspect-start2.ts
Normal file
14
scripts/inspect-start2.ts
Normal file
@@ -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);
|
||||||
65
tests/brochure-modal.test.ts
Normal file
65
tests/brochure-modal.test.ts
Normal file
@@ -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