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 { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { config } from '@/lib/config';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
@@ -61,6 +59,7 @@ export const viewport: Viewport = {
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
import AutoBrochureModal from '@/components/AutoBrochureModal';
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -77,7 +76,7 @@ export default async function Layout(props: {
|
||||
let messages: Record<string, any> = {};
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
messages = {};
|
||||
}
|
||||
|
||||
@@ -161,6 +160,8 @@ export default async function Layout(props: {
|
||||
|
||||
<AnalyticsShell />
|
||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||
|
||||
<AutoBrochureModal />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
{/* Hero Section */}
|
||||
<section className="relative 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">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge
|
||||
@@ -107,13 +106,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-accent italic">{chunks}</span>
|
||||
),
|
||||
})}
|
||||
</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="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||
<div className="max-w-2xl text-center lg:text-left">
|
||||
<h2 className="text-2xl md:text-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')}
|
||||
</h2>
|
||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||
|
||||
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||
{t('michael.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-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>
|
||||
</Heading>
|
||||
<div className="relative mb-6 md:mb-12">
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||
<p className="text-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')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
alt={t('michael.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
alt={t('klaus.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||
{t('klaus.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||
{t('klaus.name')}
|
||||
</Heading>
|
||||
<div className="relative mb-6 md:mb-12">
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||
<p className="text-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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+97
-61
@@ -3,76 +3,112 @@
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
message: `Brochure download request (${locale})`,
|
||||
type: 'brochure_download' as any,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||
} catch (error) {
|
||||
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||
}
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
message: `Brochure download request (${locale})`,
|
||||
type: 'brochure_download' as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Return the brochure URL
|
||||
const brochureUrl = `/brochure/klz-product-catalog-${locale}.pdf`;
|
||||
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' });
|
||||
}
|
||||
|
||||
return { success: true, brochureUrl };
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Send Brochure via Email
|
||||
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
|
||||
|
||||
try {
|
||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||
const { render } = await import('@mintel/mail');
|
||||
const React = await import('react');
|
||||
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||
|
||||
const html = await render(
|
||||
React.createElement(BrochureDeliveryEmail, {
|
||||
email,
|
||||
brochureUrl,
|
||||
locale: locale as 'en' | 'de',
|
||||
}),
|
||||
);
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
|
||||
html,
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Brochure email sent successfully', { email });
|
||||
} else {
|
||||
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||
action: 'requestBrochureAction_email',
|
||||
email,
|
||||
});
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Exception while sending brochure email', { error });
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
|
||||
// 4. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
delivery_method: 'email',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
export default function AutoBrochureModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already seen or interacted with the modal
|
||||
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
|
||||
|
||||
if (!hasSeenModal) {
|
||||
// Auto-open after 5 seconds to not interrupt immediate page load
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
// Mark as seen so it doesn't bother them again on next page load
|
||||
localStorage.setItem('klz_brochure_modal_seen', 'true');
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
|
||||
}
|
||||
+418
-213
@@ -9,8 +9,8 @@ import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,235 +19,440 @@ interface Props {
|
||||
* No direct download link is exposed anywhere.
|
||||
*/
|
||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
|
||||
const [url, setUrl] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
function openModal() {
|
||||
setOpen(true);
|
||||
}
|
||||
function closeModal() {
|
||||
setOpen(false);
|
||||
setPhase('form');
|
||||
setErr('');
|
||||
}
|
||||
|
||||
function openModal() { setOpen(true); }
|
||||
function closeModal() {
|
||||
setOpen(false);
|
||||
setPhase('form');
|
||||
setUrl('');
|
||||
setErr('');
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
setPhase('loading');
|
||||
// ── Trigger Button ─────────────────────────────────────────────────
|
||||
const trigger = (
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
|
||||
const fd = new FormData(formRef.current);
|
||||
fd.set('locale', locale);
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
try {
|
||||
const res = await requestBrochureAction(fd);
|
||||
if (res.success && res.brochureUrl) {
|
||||
setUrl(res.brochureUrl);
|
||||
setPhase('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setErr(res.error || 'Error');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error');
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
|
||||
PDF Katalog
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}
|
||||
>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
// ── Trigger Button ─────────────────────────────────────────────────
|
||||
const trigger = (
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">PDF Katalog</span>
|
||||
<span className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────────
|
||||
const modal = mounted && open ? createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
|
||||
// ── Modal ──────────────────────────────────────────────────────────
|
||||
const modal =
|
||||
mounted && open
|
||||
? createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={closeModal}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: '26rem', borderRadius: '1.5rem', background: '#000d26', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 40px 80px rgba(0,0,0,0.6)', overflow: 'hidden' }}>
|
||||
<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 */}
|
||||
<div style={{ height: '3px', background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)' }} />
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '2rem',
|
||||
height: '2rem',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2rem', height: '2rem', borderRadius: '50%', background: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.4)', border: 'none', cursor: 'pointer' }}
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<div style={{ padding: '2rem' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '2.75rem',
|
||||
height: '2.75rem',
|
||||
borderRadius: '0.75rem',
|
||||
background: 'rgba(130,237,32,0.1)',
|
||||
border: '1px solid rgba(130,237,32,0.2)',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style={{ padding: '2rem' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.75rem', height: '2.75rem', borderRadius: '0.75rem', background: 'rgba(130,237,32,0.1)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
|
||||
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', color: 'rgba(255,255,255,0.5)', lineHeight: 1.6 }}>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{phase === 'success' ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', borderRadius: '1rem', background: 'rgba(130,237,32,0.08)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.25rem', height: '2.25rem', borderRadius: '0.625rem', background: 'rgba(130,237,32,0.15)', flexShrink: 0 }}>
|
||||
<svg width="18" height="18" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 700, color: '#82ed20' }}>{t('successTitle')}</p>
|
||||
<p style={{ margin: '0.125rem 0 0', fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>{t('successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%', padding: '1rem', borderRadius: '1rem', background: '#82ed20', color: '#000d26', fontWeight: 900, fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.1em', textDecoration: 'none' }}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<label style={{ display: 'block', fontSize: '0.625rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)', marginBottom: '0.5rem' }}>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
style={{ width: '100%', padding: '0.875rem 1rem', borderRadius: '0.75rem', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.875rem', fontWeight: 500, outline: 'none', boxSizing: 'border-box', marginBottom: '0.75rem' }}
|
||||
/>
|
||||
|
||||
{phase === 'error' && err && (
|
||||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.75rem', color: '#f87171', fontWeight: 500 }}>{err}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
|
||||
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
border: 'none',
|
||||
cursor: phase === 'loading' ? 'wait' : 'pointer',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 900,
|
||||
color: '#fff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '-0.03em',
|
||||
lineHeight: 1,
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.875rem',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
{phase === 'success' ? (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: 'rgba(130,237,32,0.08)',
|
||||
border: '1px solid rgba(130,237,32,0.2)',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '2.25rem',
|
||||
height: '2.25rem',
|
||||
borderRadius: '0.625rem',
|
||||
background: 'rgba(130,237,32,0.15)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
fill="none"
|
||||
stroke="#82ed20"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 700,
|
||||
color: '#82ed20',
|
||||
}}
|
||||
>
|
||||
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.125rem 0 0',
|
||||
fontSize: '0.75rem',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
}}
|
||||
>
|
||||
{locale === 'de'
|
||||
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||
: 'Please check your inbox.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 900,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.2em',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.875rem 1rem',
|
||||
borderRadius: '0.75rem',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
|
||||
{phase === 'error' && err && (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 0.75rem',
|
||||
fontSize: '0.75rem',
|
||||
color: '#f87171',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
|
||||
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
border: 'none',
|
||||
cursor: phase === 'loading' ? 'wait' : 'pointer',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.625rem',
|
||||
color: 'rgba(255,255,255,0.25)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+199
-186
@@ -9,203 +9,216 @@ import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface BrochureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [brochureUrl, setBrochureUrl] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Mount guard for SSR/portal
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Close on escape + lock scroll
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData(formRef.current);
|
||||
formData.set('locale', locale);
|
||||
|
||||
const result = await requestBrochureAction(formData);
|
||||
|
||||
if (result.success && result.brochureUrl) {
|
||||
setState('success');
|
||||
setBrochureUrl(result.brochureUrl);
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setState('error');
|
||||
setErrorMsg(result.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMsg('Network error');
|
||||
}
|
||||
// Close on escape + lock scroll
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState('idle');
|
||||
setBrochureUrl(null);
|
||||
setErrorMsg('');
|
||||
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]);
|
||||
|
||||
if (!mounted || !isOpen) return null;
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
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 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden">
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg className="h-6 w-6 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state === 'success' && brochureUrl ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg className="h-5 w-5 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">{t('successTitle')}</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">{t('successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={brochureUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26] font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div 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>
|
||||
</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 id = textContent
|
||||
? textContent
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[*_`]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[*_`]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
: undefined;
|
||||
|
||||
if (tag === 'h1')
|
||||
return (
|
||||
<h2
|
||||
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}
|
||||
</h2>
|
||||
@@ -82,7 +82,7 @@ const jsxConverters: JSXConverters = {
|
||||
return (
|
||||
<h3
|
||||
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}
|
||||
</h3>
|
||||
@@ -91,7 +91,7 @@ const jsxConverters: JSXConverters = {
|
||||
return (
|
||||
<h4
|
||||
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}
|
||||
</h4>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
} from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
interface BrochureDeliveryEmailProps {
|
||||
_email: string;
|
||||
brochureUrl: string;
|
||||
locale: 'en' | 'de';
|
||||
}
|
||||
|
||||
export const BrochureDeliveryEmail = ({
|
||||
_email,
|
||||
brochureUrl,
|
||||
locale = 'en',
|
||||
}: BrochureDeliveryEmailProps) => {
|
||||
const t =
|
||||
locale === 'de'
|
||||
? {
|
||||
subject: 'Ihr KLZ Kabelkatalog',
|
||||
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
|
||||
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||
button: 'Katalog herunterladen',
|
||||
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
|
||||
}
|
||||
: {
|
||||
subject: 'Your KLZ Cable Catalog',
|
||||
greeting: 'Thank you for your interest in KLZ Cables.',
|
||||
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||
button: 'Download Catalog',
|
||||
footer: 'This email was sent from klz-cables.com.',
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{t.subject}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={headerSection}>
|
||||
<Heading style={h1}>{t.subject}</Heading>
|
||||
</Section>
|
||||
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>{t.greeting}</strong>
|
||||
</Text>
|
||||
<Text style={text}>{t.body}</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={brochureUrl}>
|
||||
{t.button}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr style={hr} />
|
||||
</Section>
|
||||
<Text style={footer}>{t.footer}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrochureDeliveryEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '0 0 48px',
|
||||
marginBottom: '64px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e6ebf1',
|
||||
};
|
||||
|
||||
const headerSection = {
|
||||
backgroundColor: '#000d26',
|
||||
padding: '32px 48px',
|
||||
borderBottom: '4px solid #4da612',
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: '#ffffff',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0',
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: '32px 48px 0',
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
textAlign: 'left' as const,
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '32px',
|
||||
marginBottom: '32px',
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#4da612',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '16px 32px',
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e6ebf1',
|
||||
margin: '20px 0',
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: '#8898aa',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '20px',
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
|
||||
fill
|
||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
quality={100}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import dynamic from 'next/dynamic';
|
||||
@@ -20,7 +19,7 @@ export default function Hero({ data }: { data?: any }) {
|
||||
<div>
|
||||
<Heading
|
||||
level={1}
|
||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-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 ? (
|
||||
<span
|
||||
@@ -28,27 +27,19 @@ export default function Hero({ data }: { data?: any }) {
|
||||
__html: data.title
|
||||
.replace(
|
||||
/<green>/g,
|
||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
||||
'<span class="text-accent italic">',
|
||||
)
|
||||
.replace(
|
||||
/<\/green>/g,
|
||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
||||
'</span>',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic inline-block">
|
||||
{chunks}
|
||||
</span>
|
||||
<div
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
||||
style={{ animationDelay: '500ms' }}
|
||||
>
|
||||
<Scribble variant="circle" />
|
||||
</div>
|
||||
<span className="text-accent italic">
|
||||
{chunks}
|
||||
</span>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
||||
fill
|
||||
className="object-cover scale-105 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
quality={100}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function VideoSection({ data }: { data?: any }) {
|
||||
@@ -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">
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||
{data?.title ? (
|
||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="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', {
|
||||
future: (chunks) => (
|
||||
<span className="relative inline-block mx-2">
|
||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-4 -bottom-2 left-0 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
<span className="italic text-accent">{chunks}</span>
|
||||
),
|
||||
})
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+400
-227
@@ -72,7 +72,9 @@ export interface BrochureProps {
|
||||
highlights?: Array<{ value: string; label: string }>;
|
||||
pullQuote?: string;
|
||||
}>;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
galleryImages?: Array<string | Buffer | undefined>;
|
||||
messages?: Record<string, any>;
|
||||
directorPhotos?: { michael?: Buffer; klaus?: Buffer };
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -86,19 +88,39 @@ const imgValid = (src?: string | Buffer): boolean => {
|
||||
};
|
||||
|
||||
const labels = (locale: 'en' | 'de') => locale === 'de' ? {
|
||||
catalog: 'Produktkatalog',
|
||||
subtitle: 'Hochwertige Stromkabel\nMittelspannungslösungen\nSolarkabel',
|
||||
about: 'Über uns', toc: 'Produktübersicht', overview: 'Produktübersicht',
|
||||
application: 'Anwendung', specs: 'Technische Daten', contact: 'Kontakt',
|
||||
qrWeb: 'Web', qrPdf: 'PDF', values: 'Unsere Werte', edition: 'Ausgabe', page: 'S.',
|
||||
property: 'Eigenschaft', value: 'Wert',
|
||||
catalog: 'Kabelkatalog',
|
||||
subtitle: 'WIR SORGEN DAFÜR, DASS DER STROM FLIESST – MIT QUALITÄTSGEPRÜFTEN KABELN. VON DER NIEDERSPANNUNG BIS ZUR HOCHSPANNUNG.',
|
||||
about: 'Über uns',
|
||||
toc: 'Inhalt',
|
||||
overview: 'Übersicht',
|
||||
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',
|
||||
subtitle: 'High-Quality Power Cables\nMedium Voltage Solutions\nSolar Cables',
|
||||
about: 'About Us', toc: 'Product Overview', overview: 'Product Overview',
|
||||
application: 'Application', specs: 'Technical Data', contact: 'Contact',
|
||||
qrWeb: 'Web', qrPdf: 'PDF', values: 'Our Values', edition: 'Edition', page: 'p.',
|
||||
property: 'Property', value: 'Value',
|
||||
catalog: 'Cable Catalog',
|
||||
subtitle: 'WE ENSURE THE CURRENT FLOWS – WITH QUALITY-TESTED CABLES. FROM LOW TO HIGH VOLTAGE.',
|
||||
about: 'About Us',
|
||||
toc: 'Contents',
|
||||
overview: 'Overview',
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
@@ -163,6 +185,69 @@ const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({
|
||||
// Green accent bar
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -171,7 +256,7 @@ const CoverPage: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
introContent?: BrochureProps['introContent'];
|
||||
logoWhite?: string | Buffer;
|
||||
galleryImages?: Array<string | Buffer>;
|
||||
galleryImages?: Array<string | Buffer | undefined>;
|
||||
}> = ({ locale, introContent, logoWhite, galleryImages }) => {
|
||||
const l = labels(locale);
|
||||
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 */}
|
||||
<View style={{ position: 'absolute', bottom: 160, left: MARGIN, right: MARGIN }}>
|
||||
<View style={{ width: 40, height: 3, backgroundColor: C.green, marginBottom: 24 }} />
|
||||
<Text style={{ fontSize: 56, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -1, lineHeight: 1 }}>
|
||||
<Text style={{ fontSize: 32, fontWeight: 700, color: C.white, textTransform: 'uppercase', letterSpacing: -0.5, lineHeight: 1.05 }}>
|
||||
{l.catalog}
|
||||
</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}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -224,131 +309,220 @@ const InfoPage: React.FC<{
|
||||
section: NonNullable<BrochureProps['marketingSections']>[0];
|
||||
image?: string | Buffer;
|
||||
logoBlack?: string | Buffer;
|
||||
logoWhite?: string | Buffer;
|
||||
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 textColor = dark ? C.gray300 : C.gray600;
|
||||
const titleColor = 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 (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
<Header logo={logoBlack} right="KLZ Cables" dark={dark} />
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: bg, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
{/* 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} />
|
||||
|
||||
{/* Full-width image at top */}
|
||||
{imgValid(image) && (
|
||||
<View style={{ height: 200, marginBottom: 28, marginHorizontal: -MARGIN }}>
|
||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{dark && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: C.navyDeep, opacity: 0.15 }} />}
|
||||
</View>
|
||||
)}
|
||||
{/* Content — pushed below image when top-position */}
|
||||
<View style={{ paddingTop: bodyTopWithImg }}>
|
||||
|
||||
{/* Label + Title */}
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
|
||||
<Text style={{ fontSize: 28, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 8 }}>{section.title}</Text>
|
||||
<AccentBar />
|
||||
{/* Label + Title */}
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{section.subtitle}</Text>
|
||||
<Text style={{ fontSize: 24, fontWeight: 700, color: titleColor, letterSpacing: -0.5, marginBottom: 16 }}>{section.title}</Text>
|
||||
|
||||
{/* Description */}
|
||||
{section.description && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<RichText style={{ fontSize: 11, color: textColor, lineHeight: 1.7 }} gap={10} color={boldColor}>
|
||||
{section.description}
|
||||
</RichText>
|
||||
</View>
|
||||
)}
|
||||
{/* Description */}
|
||||
{section.description && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<RichText style={{ fontSize: 10, color: textColor, lineHeight: 1.7 }} gap={8} color={boldColor}>
|
||||
{section.description}
|
||||
</RichText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Highlights — horizontal stat cards */}
|
||||
{section.highlights && section.highlights.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
|
||||
{section.highlights.map((h, i) => (
|
||||
<View key={i} style={{
|
||||
flex: 1,
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
|
||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||
paddingVertical: 14, paddingHorizontal: 14,
|
||||
}}>
|
||||
<Text style={{ fontSize: 20, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
|
||||
<Text style={{ fontSize: 8, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{/* Highlights */}
|
||||
{section.highlights && section.highlights.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
|
||||
{section.highlights.map((h, i) => (
|
||||
<View key={i} style={{
|
||||
flex: 1,
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : C.offWhite,
|
||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||
paddingVertical: 12, paddingHorizontal: 12,
|
||||
}}>
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: dark ? C.white : C.navy, marginBottom: 4 }}>{h.value}</Text>
|
||||
<Text style={{ fontSize: 7, color: dark ? C.gray400 : C.gray600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{h.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Pull quote */}
|
||||
{section.pullQuote && (
|
||||
<View style={{
|
||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||
paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
|
||||
}}>
|
||||
<Text style={{ fontSize: 14, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
|
||||
„{section.pullQuote}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Pull quote */}
|
||||
{section.pullQuote && (
|
||||
<View style={{
|
||||
borderLeftWidth: 3, borderLeftColor: C.green, borderLeftStyle: 'solid',
|
||||
paddingLeft: 16, paddingVertical: 8, marginBottom: 24,
|
||||
}}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700, color: titleColor, lineHeight: 1.5 }}>
|
||||
„{section.pullQuote}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Items — 2-column grid with accent bars */}
|
||||
{section.items && section.items.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
||||
{section.items.map((item, i) => (
|
||||
<View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
|
||||
<View style={{ width: 20, height: 2, backgroundColor: C.green, marginBottom: 8 }} />
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: titleColor, marginBottom: 4 }}>{item.title}</Text>
|
||||
<RichText style={{ fontSize: 9, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
|
||||
{item.description}
|
||||
</RichText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{/* Items — 2-column grid */}
|
||||
{section.items && section.items.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
||||
{section.items.map((item, i) => (
|
||||
<View key={i} style={{ width: '46%' }} minPresenceAhead={60}>
|
||||
<Text style={{ fontSize: 9, 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: 8.5, color: textColor, lineHeight: 1.6 }} gap={4} color={boldColor}>
|
||||
{item.description}
|
||||
</RichText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
// About page (first info page, special layout with values grid)
|
||||
// About page (first info page)
|
||||
const AboutPage: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
companyInfo: BrochureProps['companyInfo'];
|
||||
logoBlack?: 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);
|
||||
|
||||
// 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 (
|
||||
<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" />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
{/* Full-width image at top */}
|
||||
{imgValid(image) && (
|
||||
<View style={{ height: 220, marginBottom: 28, marginHorizontal: -MARGIN }}>
|
||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{/* Content pushed below the fading image */}
|
||||
<View style={{ paddingTop: bodyTopWithImg }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.about}</Text>
|
||||
<Text style={{ fontSize: 32, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>KLZ Cables</Text>
|
||||
<AccentBar />
|
||||
|
||||
<RichText style={{ fontSize: 13, color: C.gray900, lineHeight: 1.8 }} gap={12}>
|
||||
{companyInfo.tagline}
|
||||
</RichText>
|
||||
|
||||
{/* Values grid */}
|
||||
<View style={{ marginTop: 32 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 16 }}>{l.values}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 20 }}>
|
||||
{companyInfo.values.map((v, i) => (
|
||||
<View key={i} style={{ width: '46%', marginBottom: 8 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||
<View style={{ width: 28, height: 28, backgroundColor: C.green, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700, color: C.white }}>0{i + 1}</Text>
|
||||
{/* Directors — two-column */}
|
||||
{(michael || klaus) && (
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12 }}>
|
||||
{locale === 'de' ? 'Die Geschäftsführer' : 'The Directors'}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 20 }}>
|
||||
{[{ data: michael, photo: directorPhotos?.michael }, { data: klaus, photo: directorPhotos?.klaus }].filter(p => p.data).map((p, i) => (
|
||||
<View key={i} style={{ flex: 1 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
||||
{p.photo && (
|
||||
<Image src={p.photo} style={{ width: 32, height: 32, borderRadius: 16 }} />
|
||||
)}
|
||||
<View>
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep, marginBottom: 1 }}>{p.data.name}</Text>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 0.8 }}>{p.data.role}</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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</Page>
|
||||
@@ -364,53 +538,52 @@ const TocPage: React.FC<{
|
||||
locale: 'en' | 'de';
|
||||
logoBlack?: string | Buffer;
|
||||
productStartPage: number;
|
||||
image?: string | Buffer;
|
||||
}> = ({ products, locale, logoBlack, productStartPage, image }) => {
|
||||
}> = ({ products, locale, logoBlack, productStartPage }) => {
|
||||
const l = labels(locale);
|
||||
|
||||
const grouped = new Map<string, Array<{ product: BrochureProduct; pageNum: number }>>();
|
||||
let idx = 0;
|
||||
// Group products by their first category
|
||||
const categories: Array<{ name: string; products: Array<BrochureProduct & { startingPage: number }> }> = [];
|
||||
let currentPageNum = productStartPage;
|
||||
for (const p of products) {
|
||||
const cat = p.categories[0]?.name || 'Other';
|
||||
if (!grouped.has(cat)) grouped.set(cat, []);
|
||||
grouped.get(cat)!.push({ product: p, pageNum: productStartPage + idx });
|
||||
idx++;
|
||||
const catName = p.categories[0]?.name || l.other;
|
||||
let category = categories.find(c => c.name === catName);
|
||||
if (!category) {
|
||||
category = { name: catName, products: [] };
|
||||
categories.push(category);
|
||||
}
|
||||
category.products.push({ ...p, startingPage: currentPageNum });
|
||||
currentPageNum++;
|
||||
}
|
||||
|
||||
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} />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
{/* Image strip */}
|
||||
{imgValid(image) && (
|
||||
<View style={{ height: 140, marginBottom: 28, marginHorizontal: -MARGIN }}>
|
||||
<Image src={image!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</View>
|
||||
)}
|
||||
<View style={{ paddingTop: BODY_TOP + 40 }}>
|
||||
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 24 }}>
|
||||
{l.catalog}
|
||||
</Text>
|
||||
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>{l.catalog}</Text>
|
||||
<Text style={{ fontSize: 28, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.5, marginBottom: 8 }}>{l.toc}</Text>
|
||||
<AccentBar />
|
||||
|
||||
{Array.from(grouped.entries()).map(([cat, items]) => (
|
||||
<View key={cat} style={{ marginBottom: 16 }}>
|
||||
<View style={{ borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 6 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2 }}>{cat}</Text>
|
||||
</View>
|
||||
{items.map((item, i) => (
|
||||
<View key={i} style={{
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||
paddingVertical: 5,
|
||||
borderBottomWidth: i < items.length - 1 ? 0.5 : 0,
|
||||
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||
}}>
|
||||
<Text style={{ fontSize: 10, fontWeight: 700, color: C.navyDeep }}>{item.product.name}</Text>
|
||||
<Text style={{ fontSize: 9, color: C.gray400 }}>{l.page} {item.pageNum}</Text>
|
||||
{categories.map((cat, i) => (
|
||||
<View key={i} style={{ marginBottom: 16 }} minPresenceAhead={40}>
|
||||
{cat.name && (
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||
{cat.name}
|
||||
</Text>
|
||||
)}
|
||||
<View style={{ flexDirection: 'column', gap: 6 }}>
|
||||
{cat.products.map((p, j) => (
|
||||
<View key={j} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 700, color: C.navyDeep }}>{p.name}</Text>
|
||||
<View style={{ flex: 1, borderBottomWidth: 1, borderBottomColor: C.gray200, borderBottomStyle: 'dotted', marginHorizontal: 8, marginBottom: 3 }} />
|
||||
<Text style={{ fontSize: 9, color: C.gray600 }}>{(p.startingPage || 0).toString().padStart(2, '0')}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -425,82 +598,45 @@ const ProductPage: React.FC<{
|
||||
logoBlack?: string | Buffer;
|
||||
}> = ({ product, locale, logoBlack }) => {
|
||||
const l = labels(locale);
|
||||
const desc = strip(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml);
|
||||
|
||||
return (
|
||||
<Page size="A4" style={{ fontFamily: 'Helvetica', backgroundColor: C.white, paddingTop: BODY_TOP, paddingBottom: BODY_BOTTOM, paddingHorizontal: MARGIN }}>
|
||||
<Header logo={logoBlack} right={l.overview} />
|
||||
<Header logo={logoBlack} right="KLZ Cables" />
|
||||
<PageFooter left="KLZ Cables" right="www.klz-cables.com" />
|
||||
|
||||
{/* Category + Name */}
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 8 }}>
|
||||
{product.categories.map(c => c.name).join(' · ')}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 24, fontWeight: 700, color: C.navyDeep, letterSpacing: -0.3, marginBottom: 8 }}>{product.name}</Text>
|
||||
<AccentBar />
|
||||
|
||||
{/* Full-width product image */}
|
||||
<View style={{
|
||||
height: 160, marginHorizontal: -MARGIN, marginBottom: 24,
|
||||
backgroundColor: C.offWhite,
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
padding: 16,
|
||||
}}>
|
||||
{product.featuredImage ? (
|
||||
<Image src={product.featuredImage} style={{ maxWidth: '80%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 10, color: C.gray400 }}>—</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Description + QR in two columns */}
|
||||
<View style={{ flexDirection: 'row', gap: 32, marginBottom: 24 }}>
|
||||
<View style={{ flex: 2 }}>
|
||||
{desc && (
|
||||
<View>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.application}</Text>
|
||||
<RichText style={{ fontSize: 9, color: C.gray600, lineHeight: 1.7 }}>{desc}</RichText>
|
||||
</View>
|
||||
)}
|
||||
{/* Product image block reduced strictly to 110pt high */}
|
||||
{product.featuredImage && (
|
||||
<View style={{ height: 110, marginBottom: 20, marginHorizontal: -MARGIN }}>
|
||||
<Image src={product.featuredImage as unknown as Buffer} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
</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 && (
|
||||
<View>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 10 }}>{l.specs}</Text>
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.green, textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 8 }}>{l.specs}</Text>
|
||||
|
||||
{/* Clean table header */}
|
||||
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 4, marginBottom: 2 }}>
|
||||
<View style={{ width: '55%' }}>
|
||||
{/* Table header */}
|
||||
<View style={{ flexDirection: 'row', borderBottomWidth: 1.5, borderBottomColor: C.navy, borderBottomStyle: 'solid', paddingBottom: 5, paddingHorizontal: 10, marginBottom: 2 }}>
|
||||
<View style={{ width: '50%' }}>
|
||||
<Text style={{ fontSize: 7, fontWeight: 700, color: C.gray400, textTransform: 'uppercase', letterSpacing: 0.8 }}>{l.property}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -511,21 +647,49 @@ const ProductPage: React.FC<{
|
||||
{product.attributes.map((attr, i) => (
|
||||
<View key={i} style={{
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 5,
|
||||
paddingVertical: 5, paddingHorizontal: 10,
|
||||
backgroundColor: i % 2 === 0 ? C.white : C.offWhite,
|
||||
borderBottomWidth: i < product.attributes.length - 1 ? 0.5 : 0,
|
||||
borderBottomColor: C.gray200, borderBottomStyle: 'solid',
|
||||
}}>
|
||||
<View style={{ width: '55%', paddingRight: 8 }}>
|
||||
<View style={{ width: '50%', paddingRight: 12 }}>
|
||||
<Text style={{ fontSize: 8, fontWeight: 700, color: C.navyDeep }}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 9, color: C.gray900 }}>{attr.options.join(', ')}</Text>
|
||||
<Text style={{ fontSize: 8, color: C.gray900 }}>{attr.options.join(', ')}</Text>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -584,19 +748,26 @@ const BackCover: React.FC<{
|
||||
|
||||
export const PDFBrochure: React.FC<BrochureProps> = ({
|
||||
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)
|
||||
const numInfoPages = 1 + (marketingSections?.length || 0); // About + sections
|
||||
const productStartPage = 1 + numInfoPages + 1; // Cover + info pages + TOC
|
||||
const numInfoPages = 1 + (marketingSections?.length || 0);
|
||||
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'> = [];
|
||||
// imagePosition: alternate between top and bottom-half for variety
|
||||
const imagePositions: Array<'top' | 'bottom-half'> = [];
|
||||
if (marketingSections) {
|
||||
for (let i = 0; i < marketingSections.length; i++) {
|
||||
// Alternate: light, dark, light, dark, light, dark
|
||||
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>
|
||||
<CoverPage locale={locale} introContent={introContent} logoWhite={logoWhite} galleryImages={galleryImages} />
|
||||
|
||||
{/* About page with image index 1 */}
|
||||
<AboutPage locale={locale} companyInfo={companyInfo} logoBlack={logoBlack} image={galleryImages?.[1]} />
|
||||
{/* About page — image[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) => (
|
||||
<InfoPage
|
||||
key={`info-${i}`}
|
||||
section={section}
|
||||
image={galleryImages?.[i + 2]}
|
||||
logoBlack={logoBlack}
|
||||
logoWhite={logoWhite}
|
||||
dark={sectionThemes[i] === 'dark'}
|
||||
imagePosition={imagePositions[i]}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* TOC */}
|
||||
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} image={galleryImages?.[5]} />
|
||||
{/* TOC — no decorative image, clean list */}
|
||||
<TocPage products={products} locale={locale} logoBlack={logoBlack} productStartPage={productStartPage} />
|
||||
|
||||
{/* Products — each on its own page */}
|
||||
{products.map(p => (
|
||||
<ProductPage key={p.id} product={p} locale={locale} logoBlack={logoBlack} />
|
||||
))}
|
||||
|
||||
{/* Back cover */}
|
||||
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[6]} />
|
||||
{/* Back cover — last gallery image */}
|
||||
<BackCover companyInfo={companyInfo} locale={locale} logoWhite={logoWhite} image={galleryImages?.[backCoverImgIdx]} />
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
+97
-94
@@ -1,13 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Font,
|
||||
} from '@react-pdf/renderer';
|
||||
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
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({
|
||||
page: {
|
||||
color: '#111827', // Text Primary
|
||||
color: C.gray900,
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 100,
|
||||
paddingBottom: 80,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 0,
|
||||
paddingHorizontal: 72,
|
||||
paddingHorizontal: MARGIN,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
header: {
|
||||
@@ -49,17 +57,17 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
logoText: {
|
||||
fontSize: 24,
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
letterSpacing: 1,
|
||||
color: C.navyDeep,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
docTitle: {
|
||||
fontSize: 10,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#001a4d',
|
||||
color: C.green,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
@@ -78,10 +86,10 @@ const styles = StyleSheet.create({
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderColor: C.gray200,
|
||||
backgroundColor: C.white,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -93,7 +101,7 @@ const styles = StyleSheet.create({
|
||||
productName: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
marginBottom: 0,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
@@ -101,7 +109,7 @@ const styles = StyleSheet.create({
|
||||
|
||||
productMeta: {
|
||||
fontSize: 10,
|
||||
color: '#4b5563',
|
||||
color: C.gray600,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
@@ -115,13 +123,13 @@ const styles = StyleSheet.create({
|
||||
|
||||
noImage: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
color: C.gray400,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: 72,
|
||||
paddingHorizontal: MARGIN,
|
||||
},
|
||||
|
||||
// Content sections
|
||||
@@ -130,40 +138,40 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#000d26', // Primary Dark
|
||||
marginBottom: 8,
|
||||
color: C.green,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.2,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
|
||||
sectionAccent: {
|
||||
width: 30,
|
||||
height: 3,
|
||||
backgroundColor: '#82ed20', // Accent Green
|
||||
height: 2,
|
||||
backgroundColor: C.green,
|
||||
marginBottom: 8,
|
||||
borderRadius: 1.5,
|
||||
borderRadius: 1,
|
||||
},
|
||||
|
||||
description: {
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.7,
|
||||
color: '#4b5563', // Text Secondary
|
||||
color: C.gray600,
|
||||
},
|
||||
|
||||
// Technical data table
|
||||
specsTable: {
|
||||
marginTop: 8,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
specsTableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: C.gray200,
|
||||
},
|
||||
|
||||
specsTableRowLast: {
|
||||
@@ -172,83 +180,85 @@ const styles = StyleSheet.create({
|
||||
|
||||
specsTableLabelCell: {
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#e5e7eb',
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: C.offWhite,
|
||||
borderRightWidth: 0.5,
|
||||
borderRightColor: C.gray200,
|
||||
},
|
||||
|
||||
specsTableValueCell: {
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
|
||||
specsTableLabelText: {
|
||||
fontSize: 9,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
specsTableValueText: {
|
||||
fontSize: 10,
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
fontSize: 9,
|
||||
color: C.gray900,
|
||||
fontWeight: 400,
|
||||
},
|
||||
|
||||
// Categories
|
||||
categories: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
gap: 6,
|
||||
},
|
||||
|
||||
categoryTag: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 100,
|
||||
backgroundColor: C.offWhite,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderWidth: 0.5,
|
||||
borderColor: C.gray200,
|
||||
borderRadius: 3,
|
||||
},
|
||||
|
||||
categoryText: {
|
||||
fontSize: 8,
|
||||
color: '#4b5563',
|
||||
fontSize: 7,
|
||||
color: C.gray600,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Footer
|
||||
// Footer — matches brochure style
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
left: 72,
|
||||
right: 72,
|
||||
bottom: 28,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: C.green,
|
||||
},
|
||||
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
fontWeight: 500,
|
||||
fontSize: 7,
|
||||
color: C.gray400,
|
||||
fontWeight: 400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 10,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -302,10 +312,7 @@ const getLabels = (locale: 'en' | 'de') => {
|
||||
return labels[locale];
|
||||
};
|
||||
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
product,
|
||||
locale,
|
||||
}) => {
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||
const labels = getLabels(locale);
|
||||
|
||||
return (
|
||||
@@ -317,9 +324,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>
|
||||
{labels.productDatasheet}
|
||||
</Text>
|
||||
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productRow}>
|
||||
@@ -328,7 +333,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<Text key={index} style={styles.productMeta}>
|
||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
{cat.name}
|
||||
{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
@@ -337,12 +343,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
</View>
|
||||
<View style={styles.productImageCol}>
|
||||
{product.featuredImage ? (
|
||||
<Image
|
||||
src={product.featuredImage}
|
||||
style={styles.heroImage}
|
||||
/>
|
||||
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||
) : (
|
||||
|
||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -356,7 +358,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
{stripHtml(
|
||||
product.applicationHtml ||
|
||||
product.shortDescriptionHtml ||
|
||||
product.descriptionHtml,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -372,17 +378,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 &&
|
||||
styles.specsTableRowLast,
|
||||
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.0.2",
|
||||
"version": "2.2.11",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
async function main() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const products: any[] = [];
|
||||
|
||||
for (const locale of ['en', 'de'] as const) {
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
locale: locale as any,
|
||||
pagination: false,
|
||||
});
|
||||
|
||||
for (const doc of result.docs) {
|
||||
if (!doc.title || !doc.slug) continue;
|
||||
|
||||
const images: string[] = [];
|
||||
if (doc.featuredImage) {
|
||||
const url =
|
||||
typeof doc.featuredImage === 'string'
|
||||
? doc.featuredImage
|
||||
: (doc.featuredImage as any).url;
|
||||
if (url) images.push(url);
|
||||
}
|
||||
|
||||
const categories = Array.isArray(doc.categories)
|
||||
? doc.categories
|
||||
.map((c: any) => ({ name: String(c.category || c) }))
|
||||
.filter((c: any) => c.name)
|
||||
: [];
|
||||
|
||||
const attributes: any[] = [];
|
||||
if (Array.isArray((doc as any).content?.root?.children)) {
|
||||
const ptBlock = (doc as any).content.root.children.find(
|
||||
(n: any) => n.type === 'block' && n.fields?.blockType === 'productTabs',
|
||||
);
|
||||
if (ptBlock?.fields?.technicalItems) {
|
||||
for (const item of ptBlock.fields.technicalItems) {
|
||||
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
|
||||
if (label && item.value) attributes.push({ name: label, options: [item.value] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
products.push({
|
||||
id: doc.id,
|
||||
name: doc.title,
|
||||
shortDescriptionHtml: (doc as any).shortDescription || '',
|
||||
descriptionHtml: '',
|
||||
images,
|
||||
featuredImage: images[0] || null,
|
||||
sku: doc.slug,
|
||||
slug: doc.slug,
|
||||
translationKey: doc.slug,
|
||||
locale,
|
||||
categories,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const outDir = path.join(process.cwd(), 'data/processed');
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(path.join(outDir, 'products.json'), JSON.stringify(products, null, 2));
|
||||
console.log(`Exported ${products.length} products to data/processed/products.json`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
+103
-95
@@ -254,91 +254,63 @@ async function loadMarketingSections(locale: 'en' | 'de'): Promise<BrochureProps
|
||||
|
||||
const sections: NonNullable<BrochureProps['marketingSections']> = [];
|
||||
|
||||
// 1. What we do — short label, long subtitle becomes the description
|
||||
if (messages.Home?.whatWeDo) {
|
||||
const label = locale === 'de' ? 'Unser Angebot' : 'Our Services';
|
||||
sections.push({
|
||||
title: messages.Home.whatWeDo.title,
|
||||
subtitle: label,
|
||||
description: messages.Home.whatWeDo.subtitle,
|
||||
items: messages.Home.whatWeDo.items,
|
||||
});
|
||||
}
|
||||
// ── 1. Was wir tun + Warum wir — MERGED into one compact section ──
|
||||
{
|
||||
const allItems: Array<{ title: string; description: string }> = [];
|
||||
|
||||
// 2. Our Legacy — with stats highlight
|
||||
if (messages.Team?.legacy) {
|
||||
const label = locale === 'de' ? 'Unsere Geschichte' : 'Our Story';
|
||||
sections.push({
|
||||
title: messages.Team.legacy.title,
|
||||
subtitle: label,
|
||||
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,
|
||||
});
|
||||
// WhatWeDo items — truncated to 1 sentence each
|
||||
if (messages.Home?.whatWeDo?.items) {
|
||||
for (const item of messages.Home.whatWeDo.items) {
|
||||
allItems.push({
|
||||
title: item.title.split('.')[0], // short title
|
||||
description: item.description.split('.')[0] + '.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (messages.Team?.michael) {
|
||||
teamItems.push({
|
||||
title: `${messages.Team.michael.name} – ${messages.Team.michael.role}`,
|
||||
description: messages.Team.michael.description,
|
||||
});
|
||||
// WhyChooseUs items — truncated to 1 sentence each
|
||||
if (messages.Home?.whyChooseUs?.items) {
|
||||
for (const item of messages.Home.whyChooseUs.items) {
|
||||
allItems.push({
|
||||
title: item.title,
|
||||
description: item.description.split('.')[0] + '.',
|
||||
});
|
||||
}
|
||||
}
|
||||
const desc = messages.Home?.meetTheTeam?.description || '';
|
||||
|
||||
sections.push({
|
||||
title,
|
||||
subtitle: label,
|
||||
title: messages.Home?.whatWeDo?.title || (locale === 'de' ? 'Was wir tun' : 'What We Do'),
|
||||
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,
|
||||
items: teamItems,
|
||||
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,
|
||||
highlights,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -395,29 +367,35 @@ async function main(): Promise<void> {
|
||||
|
||||
console.log(`Logos: white=${!!logoWhite} black=${!!logoBlack}`);
|
||||
|
||||
// Load gallery images — 7 diverse images for different sections
|
||||
const galleryPaths = [
|
||||
'uploads/2024/12/DSC07433-Large-600x400.webp', // 0: Cover
|
||||
'uploads/2024/12/DSC07460-Large-600x400.webp', // 1: About section
|
||||
'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/2025/01/power-grid-station-electrical-distribution-statio-2023-11-27-05-25-36-utc-scaled.webp', // 3: After Legacy
|
||||
'uploads/2025/01/transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled.webp', // 4: After Experience
|
||||
'uploads/2024/12/DSC07539-Large-600x400.webp', // 5: TOC page
|
||||
'uploads/2025/01/business-planning-hand-using-laptop-for-working-te-2024-11-01-21-25-44-utc-scaled.webp', // 6: Back cover
|
||||
// EXACT image mapping — 2 marketing sections now
|
||||
// Index map: 0=Cover, 1=About, 2=WasWirTun(null), 3=Erfahrung(Legacy image), 4=BackCover
|
||||
const galleryPaths: Array<string | null> = [
|
||||
'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/2024/12/DSC07460-Large-600x400.webp', // 1: About section
|
||||
null, // 2: Was wir tun (NO IMAGE — text-heavy)
|
||||
'uploads/2024/12/1694273920124-copy.webp', // 3: Erfahrung & Qualität
|
||||
'uploads/2024/12/DSC07433-Large-600x400.webp', // 4: Back cover
|
||||
];
|
||||
const galleryImages: (string | Buffer)[] = [];
|
||||
|
||||
const galleryImages: (string | Buffer | undefined)[] = [];
|
||||
for (const gp of galleryPaths) {
|
||||
if (!gp) {
|
||||
galleryImages.push(undefined);
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(process.cwd(), 'public', gp);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
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);
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
galleryImages.push(undefined);
|
||||
}
|
||||
} 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) {
|
||||
console.log(`\nGenerating ${locale.toUpperCase()} brochure...`);
|
||||
@@ -430,12 +408,42 @@ async function main(): Promise<void> {
|
||||
if (products.length === 0) continue;
|
||||
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 {
|
||||
// Render the React-PDF brochure
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(PDFBrochure, {
|
||||
products, locale, companyInfo, introContent,
|
||||
marketingSections, logoBlack, logoWhite, galleryImages,
|
||||
marketingSections, logoBlack, logoWhite, galleryImages, messages, directorPhotos,
|
||||
} as any) as any
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../payload.config';
|
||||
|
||||
async function main() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: 'start' } },
|
||||
locale: 'de',
|
||||
depth: 2,
|
||||
});
|
||||
console.log(JSON.stringify(result.docs[0], null, 2));
|
||||
}
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect, beforeAll, afterAll } from 'vitest';
|
||||
import puppeteer, { Browser, Page } from 'puppeteer';
|
||||
import { createServer } from 'http';
|
||||
import { parse } from 'url';
|
||||
import next from 'next';
|
||||
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
// The URL of the local Next.js server we are testing against.
|
||||
// In CI, we expect the server to already be running at http://127.0.0.1:3000
|
||||
const BASE_URL = process.env.TEST_URL || 'http://127.0.0.1:3000';
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('AutoBrochureModal should open after 5 seconds and submit email', async () => {
|
||||
// Set a long timeout for the test to prevent premature failure
|
||||
page = await browser.newPage();
|
||||
|
||||
// Clear localStorage to ensure the auto-open logic fires
|
||||
await page.goto(BASE_URL);
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// Reload page to start the timer fresh
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle0' });
|
||||
|
||||
// Wait for auto-trigger (set to 5s in code, we wait up to 10s here)
|
||||
const modal = await page.waitForSelector('div[role="dialog"]', { timeout: 10000 });
|
||||
expect(modal).not.toBeNull();
|
||||
|
||||
// Ensure the email input is present inside the modal (using the placeholder to be sure)
|
||||
const emailInput = await page.waitForSelector('input[name="email"]', { visible: true });
|
||||
expect(emailInput).not.toBeNull();
|
||||
|
||||
// Fill the form matching BrochureCTA.tsx id=brochure-email
|
||||
await page.type('input[name="email"]', 'test-brochure@mintel.me');
|
||||
|
||||
// Submit the form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for the success state UI to appear
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.body.innerText.includes('Successfully sent') ||
|
||||
document.body.innerText.includes('Erfolgreich gesendet'),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
// Verify localStorage was set correctly so it doesn't open again
|
||||
const hasSeenModal = await page.evaluate(() => localStorage.getItem('klz_brochure_modal_seen'));
|
||||
expect(hasSeenModal).toBe('true');
|
||||
}, 30000);
|
||||
Reference in New Issue
Block a user