feat: auto-opening brochure modal with mintel/mail delivery
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m53s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s

- implemented BrochureDeliveryEmail template

- created AutoBrochureModal wrapper with 5s delay

- updated layout.tsx and BrochureCTA to use new success state

- added tests/brochure-modal.test.ts e2e test
This commit is contained in:
2026-03-02 23:08:05 +01:00
parent d2418b5720
commit 02be8e59b2
62 changed files with 4474 additions and 975 deletions

View File

@@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next'; import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import { Suspense } from 'react';
import '../../styles/globals.css'; import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { config } from '@/lib/config';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper'; import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
@@ -61,6 +59,7 @@ export const viewport: Viewport = {
themeColor: '#001a4d', themeColor: '#001a4d',
}; };
import AutoBrochureModal from '@/components/AutoBrochureModal';
export default async function Layout(props: { export default async function Layout(props: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
@@ -77,7 +76,7 @@ export default async function Layout(props: {
let messages: Record<string, any> = {}; let messages: Record<string, any> = {};
try { try {
messages = await getMessages(); messages = await getMessages();
} catch (error) { } catch {
messages = {}; messages = {};
} }
@@ -161,6 +160,8 @@ export default async function Layout(props: {
<AnalyticsShell /> <AnalyticsShell />
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} /> <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
<AutoBrochureModal />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -3,76 +3,112 @@
import { getServerAppServices } from '@/lib/services/create-services.server'; import { getServerAppServices } from '@/lib/services/create-services.server';
export async function requestBrochureAction(formData: FormData) { export async function requestBrochureAction(formData: FormData) {
const services = getServerAppServices(); const services = getServerAppServices();
const logger = services.logger.child({ action: 'requestBrochureAction' }); const logger = services.logger.child({ action: 'requestBrochureAction' });
const { headers } = await import('next/headers'); const { headers } = await import('next/headers');
const requestHeaders = await headers(); const requestHeaders = await headers();
if ('setServerContext' in services.analytics) { if ('setServerContext' in services.analytics) {
(services.analytics as any).setServerContext({ (services.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined, userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined, language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined, referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined, ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
}); });
} }
services.analytics.track('brochure-request-attempt'); services.analytics.track('brochure-request-attempt');
const email = formData.get('email') as string; const email = formData.get('email') as string;
const locale = (formData.get('locale') as string) || 'en'; const locale = (formData.get('locale') as string) || 'en';
if (!email) { if (!email) {
logger.warn('Missing email in brochure request'); logger.warn('Missing email in brochure request');
return { success: false, error: 'Missing email address' }; return { success: false, error: 'Missing email address' };
} }
// Basic email validation // Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { success: false, error: 'Invalid email address' }; return { success: false, error: 'Invalid email address' };
} }
// 1. Save to CMS // 1. Save to CMS
try { try {
const { getPayload } = await import('payload'); const { getPayload } = await import('payload');
const configPromise = (await import('@payload-config')).default; const configPromise = (await import('@payload-config')).default;
const payload = await getPayload({ config: configPromise }); const payload = await getPayload({ config: configPromise });
await payload.create({ await payload.create({
collection: 'form-submissions', collection: 'form-submissions',
data: { data: {
name: email.split('@')[0], name: email.split('@')[0],
email, email,
message: `Brochure download request (${locale})`, message: `Brochure download request (${locale})`,
type: 'brochure_download' as any, type: 'brochure_download' as any,
}, },
});
logger.info('Successfully saved brochure request to Payload CMS', { email });
} catch (error) {
logger.error('Failed to store brochure request in Payload CMS', { error });
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
}
// 2. Notify via Gotify
try {
await services.notifications.notify({
title: '📑 Brochure Download Request',
message: `New brochure download request from ${email} (${locale})`,
priority: 3,
});
} catch (error) {
logger.error('Failed to send notification', { error });
}
// 3. Track success
services.analytics.track('brochure-request-success', {
locale,
}); });
// Return the brochure URL logger.info('Successfully saved brochure request to Payload CMS', { email });
const brochureUrl = `/brochure/klz-product-catalog-${locale}.pdf`; } catch (error) {
logger.error('Failed to store brochure request in Payload CMS', { error });
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
}
return { success: true, brochureUrl }; // 2. Notify via Gotify
try {
await services.notifications.notify({
title: '📑 Brochure Download Request',
message: `New brochure download request from ${email} (${locale})`,
priority: 3,
});
} catch (error) {
logger.error('Failed to send notification', { error });
}
// 3. Send Brochure via Email
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
try {
const { sendEmail } = await import('@/lib/mail/mailer');
const { render } = await import('@mintel/mail');
const React = await import('react');
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
const html = await render(
React.createElement(BrochureDeliveryEmail, {
email,
brochureUrl,
locale: locale as 'en' | 'de',
}),
);
const emailResult = await sendEmail({
to: email,
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
html,
});
if (emailResult.success) {
logger.info('Brochure email sent successfully', { email });
} else {
logger.error('Failed to send brochure email', { error: emailResult.error, email });
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
action: 'requestBrochureAction_email',
email,
});
return { success: false, error: 'Failed to send email. Please try again later.' };
}
} catch (error) {
logger.error('Exception while sending brochure email', { error });
return { success: false, error: 'Failed to send email. Please try again later.' };
}
// 4. Track success
services.analytics.track('brochure-request-success', {
locale,
delivery_method: 'email',
});
return { success: true };
} }

View File

@@ -0,0 +1,28 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
export default function AutoBrochureModal() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Check if user has already seen or interacted with the modal
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
if (!hasSeenModal) {
// Auto-open after 5 seconds to not interrupt immediate page load
const timer = setTimeout(() => {
setIsOpen(true);
// Mark as seen so it doesn't bother them again on next page load
localStorage.setItem('klz_brochure_modal_seen', 'true');
}, 5000);
return () => clearTimeout(timer);
}
}, []);
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
}

View File

@@ -9,8 +9,8 @@ import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events'; import { AnalyticsEvents } from './analytics/analytics-events';
interface Props { interface Props {
className?: string; className?: string;
compact?: boolean; compact?: boolean;
} }
/** /**
@@ -19,235 +19,440 @@ interface Props {
* No direct download link is exposed anywhere. * No direct download link is exposed anywhere.
*/ */
export default function BrochureCTA({ className, compact = false }: Props) { export default function BrochureCTA({ className, compact = false }: Props) {
const t = useTranslations('Brochure'); const t = useTranslations('Brochure');
const locale = useLocale(); const locale = useLocale();
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form'); const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
const [url, setUrl] = useState(''); const [err, setErr] = useState('');
const [err, setErr] = useState('');
useEffect(() => { setMounted(true); }, []); useEffect(() => {
setMounted(true);
}, []);
useEffect(() => { function openModal() {
if (!open) return; setOpen(true);
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); }; }
document.addEventListener('keydown', onKey); function closeModal() {
document.body.style.overflow = 'hidden'; setOpen(false);
return () => { setPhase('form');
document.removeEventListener('keydown', onKey); setErr('');
document.body.style.overflow = ''; }
};
}, [open]);
function openModal() { setOpen(true); } useEffect(() => {
function closeModal() { if (!open) return;
setOpen(false); const onKey = (e: KeyboardEvent) => {
setPhase('form'); if (e.key === 'Escape') closeModal();
setUrl(''); };
setErr(''); document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!formRef.current) return;
setPhase('loading');
const fd = new FormData(formRef.current);
fd.set('locale', locale);
try {
const res = await requestBrochureAction(fd);
if (res.success) {
setPhase('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'brochure_modal',
});
} else {
setErr(res.error || 'Error');
setPhase('error');
}
} catch {
setErr('Network error');
setPhase('error');
} }
}
async function handleSubmit(e: React.FormEvent) { // ── Trigger Button ─────────────────────────────────────────────────
e.preventDefault(); const trigger = (
if (!formRef.current) return; <div className={cn(className)}>
setPhase('loading'); <button
type="button"
onClick={openModal}
className={cn(
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
)}
>
{/* Green top accent */}
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
const fd = new FormData(formRef.current); {/* Icon */}
fd.set('locale', locale); <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
<svg
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</span>
try { {/* Labels */}
const res = await requestBrochureAction(fd); <span className="flex-1 min-w-0">
if (res.success && res.brochureUrl) { <span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
setUrl(res.brochureUrl); PDF Katalog
setPhase('success'); </span>
trackEvent(AnalyticsEvents.DOWNLOAD, { <span
file_name: `klz-product-catalog-${locale}.pdf`, className={cn(
file_type: 'brochure', 'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
location: 'brochure_modal', compact ? 'text-base' : 'text-lg md:text-xl',
}); )}
} else { >
setErr(res.error || 'Error'); {t('ctaTitle')}
setPhase('error'); </span>
} </span>
} catch {
setErr('Network error');
setPhase('error');
}
}
// ── Trigger Button ───────────────────────────────────────────────── {/* Arrow */}
const trigger = ( <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
<div className={cn(className)}> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
type="button" </svg>
onClick={openModal} </span>
className={cn( </button>
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer', </div>
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]', );
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
)}
>
{/* Green top accent */}
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
{/* Icon */} // ── Modal ──────────────────────────────────────────────────────────
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300"> const modal =
<svg className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> mounted && open
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} ? createPortal(
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> <div
</svg> style={{
</span> position: 'fixed',
inset: 0,
{/* Labels */} zIndex: 9999,
<span className="flex-1 min-w-0"> display: 'flex',
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">PDF Katalog</span> alignItems: 'center',
<span className={cn( justifyContent: 'center',
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200', padding: '1rem',
compact ? 'text-base' : 'text-lg md:text-xl', }}
)}> >
{t('ctaTitle')}
</span>
</span>
{/* Arrow */}
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</span>
</button>
</div>
);
// ── Modal ──────────────────────────────────────────────────────────
const modal = mounted && open ? createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
{/* Backdrop */} {/* Backdrop */}
<div <div
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }} style={{
onClick={closeModal} position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.75)',
backdropFilter: 'blur(4px)',
}}
onClick={closeModal}
/> />
{/* Panel */} {/* Panel */}
<div style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: '26rem', borderRadius: '1.5rem', background: '#000d26', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 40px 80px rgba(0,0,0,0.6)', overflow: 'hidden' }}> <div
style={{
position: 'relative',
zIndex: 1,
width: '100%',
maxWidth: '26rem',
borderRadius: '1.5rem',
background: '#000d26',
border: '1px solid rgba(255,255,255,0.1)',
boxShadow: '0 40px 80px rgba(0,0,0,0.6)',
overflow: 'hidden',
}}
>
{/* Green top bar */}
<div
style={{
height: '3px',
background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)',
}}
/>
{/* Green top bar */} {/* Close */}
<div style={{ height: '3px', background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)' }} /> <button
type="button"
onClick={closeModal}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '2rem',
height: '2rem',
borderRadius: '50%',
background: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.4)',
border: 'none',
cursor: 'pointer',
}}
aria-label={t('close')}
>
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/* Close */} <div style={{ padding: '2rem' }}>
<button {/* Header */}
type="button" <div style={{ marginBottom: '1.5rem' }}>
onClick={closeModal} <div
style={{ position: 'absolute', top: '1rem', right: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2rem', height: '2rem', borderRadius: '50%', background: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.4)', border: 'none', cursor: 'pointer' }} style={{
aria-label={t('close')} display: 'flex',
> alignItems: 'center',
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"> justifyContent: 'center',
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> width: '2.75rem',
height: '2.75rem',
borderRadius: '0.75rem',
background: 'rgba(130,237,32,0.1)',
border: '1px solid rgba(130,237,32,0.2)',
marginBottom: '1rem',
}}
>
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg> </svg>
</button> </div>
<h2
<div style={{ padding: '2rem' }}> style={{
{/* Header */} margin: 0,
<div style={{ marginBottom: '1.5rem' }}> fontSize: '1.5rem',
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.75rem', height: '2.75rem', borderRadius: '0.75rem', background: 'rgba(130,237,32,0.1)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}> fontWeight: 900,
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24"> color: '#fff',
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} textTransform: 'uppercase',
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> letterSpacing: '-0.03em',
</svg> lineHeight: 1,
</div> marginBottom: '0.5rem',
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1, marginBottom: '0.5rem' }}> }}
{t('title')} >
</h2> {t('title')}
<p style={{ margin: 0, fontSize: '0.875rem', color: 'rgba(255,255,255,0.5)', lineHeight: 1.6 }}> </h2>
{t('subtitle')} <p
</p> style={{
</div> margin: 0,
fontSize: '0.875rem',
{phase === 'success' ? ( color: 'rgba(255,255,255,0.5)',
<div> lineHeight: 1.6,
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', borderRadius: '1rem', background: 'rgba(130,237,32,0.08)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}> }}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.25rem', height: '2.25rem', borderRadius: '0.625rem', background: 'rgba(130,237,32,0.15)', flexShrink: 0 }}> >
<svg width="18" height="18" fill="none" stroke="#82ed20" viewBox="0 0 24 24"> {t('subtitle')}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> </p>
</svg>
</div>
<div>
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 700, color: '#82ed20' }}>{t('successTitle')}</p>
<p style={{ margin: '0.125rem 0 0', fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>{t('successDesc')}</p>
</div>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%', padding: '1rem', borderRadius: '1rem', background: '#82ed20', color: '#000d26', fontWeight: 900, fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.1em', textDecoration: 'none' }}
>
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{t('download')}
</a>
</div>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<label style={{ display: 'block', fontSize: '0.625rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)', marginBottom: '0.5rem' }}>
{t('emailLabel')}
</label>
<input
name="email"
type="email"
required
autoComplete="email"
placeholder={t('emailPlaceholder')}
disabled={phase === 'loading'}
style={{ width: '100%', padding: '0.875rem 1rem', borderRadius: '0.75rem', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.875rem', fontWeight: 500, outline: 'none', boxSizing: 'border-box', marginBottom: '0.75rem' }}
/>
{phase === 'error' && err && (
<p style={{ margin: '0 0 0.75rem', fontSize: '0.75rem', color: '#f87171', fontWeight: 500 }}>{err}</p>
)}
<button
type="submit"
disabled={phase === 'loading'}
style={{
width: '100%',
padding: '1rem',
borderRadius: '1rem',
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
fontWeight: 900,
fontSize: '0.8125rem',
textTransform: 'uppercase',
letterSpacing: '0.1em',
border: 'none',
cursor: phase === 'loading' ? 'wait' : 'pointer',
marginBottom: '0.75rem',
}}
>
{phase === 'loading' ? t('submitting') : t('submit')}
</button>
<p style={{ margin: 0, fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)', textAlign: 'center', lineHeight: 1.6 }}>
{t('privacyNote')}
</p>
</form>
)}
</div> </div>
</div>
</div>,
document.body,
) : null;
return ( {phase === 'success' ? (
<> <div>
{trigger} <div
{modal} style={{
</> display: 'flex',
); alignItems: 'center',
gap: '0.75rem',
padding: '1rem',
borderRadius: '1rem',
background: 'rgba(130,237,32,0.08)',
border: '1px solid rgba(130,237,32,0.2)',
marginBottom: '1rem',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '2.25rem',
height: '2.25rem',
borderRadius: '0.625rem',
background: 'rgba(130,237,32,0.15)',
flexShrink: 0,
}}
>
<svg
width="18"
height="18"
fill="none"
stroke="#82ed20"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<p
style={{
margin: 0,
fontSize: '0.875rem',
fontWeight: 700,
color: '#82ed20',
}}
>
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
</p>
<p
style={{
margin: '0.125rem 0 0',
fontSize: '0.75rem',
color: 'rgba(255,255,255,0.5)',
}}
>
{locale === 'de'
? 'Bitte prüfen Sie Ihren Posteingang.'
: 'Please check your inbox.'}
</p>
</div>
</div>
<button
type="button"
onClick={closeModal}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
width: '100%',
padding: '1rem',
borderRadius: '1rem',
background: 'rgba(255,255,255,0.1)',
color: '#fff',
fontWeight: 900,
fontSize: '0.8125rem',
textTransform: 'uppercase',
letterSpacing: '0.1em',
border: 'none',
cursor: 'pointer',
}}
>
{t('close')}
</button>
</div>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<label
style={{
display: 'block',
fontSize: '0.625rem',
fontWeight: 900,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.4)',
marginBottom: '0.5rem',
}}
>
{t('emailLabel')}
</label>
<input
name="email"
type="email"
required
autoComplete="email"
placeholder={t('emailPlaceholder')}
disabled={phase === 'loading'}
style={{
width: '100%',
padding: '0.875rem 1rem',
borderRadius: '0.75rem',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#fff',
fontSize: '0.875rem',
fontWeight: 500,
outline: 'none',
boxSizing: 'border-box',
marginBottom: '0.75rem',
}}
/>
{phase === 'error' && err && (
<p
style={{
margin: '0 0 0.75rem',
fontSize: '0.75rem',
color: '#f87171',
fontWeight: 500,
}}
>
{err}
</p>
)}
<button
type="submit"
disabled={phase === 'loading'}
style={{
width: '100%',
padding: '1rem',
borderRadius: '1rem',
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
fontWeight: 900,
fontSize: '0.8125rem',
textTransform: 'uppercase',
letterSpacing: '0.1em',
border: 'none',
cursor: phase === 'loading' ? 'wait' : 'pointer',
marginBottom: '0.75rem',
}}
>
{phase === 'loading' ? t('submitting') : t('submit')}
</button>
<p
style={{
margin: 0,
fontSize: '0.625rem',
color: 'rgba(255,255,255,0.25)',
textAlign: 'center',
lineHeight: 1.6,
}}
>
{t('privacyNote')}
</p>
</form>
)}
</div>
</div>
</div>,
document.body,
)
: null;
return (
<>
{trigger}
{modal}
</>
);
} }

View File

@@ -9,203 +9,216 @@ import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events'; import { AnalyticsEvents } from './analytics/analytics-events';
interface BrochureModalProps { interface BrochureModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) { export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
const t = useTranslations('Brochure'); const t = useTranslations('Brochure');
const locale = useLocale(); const locale = useLocale();
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [mounted, setMounted] = useState(false); const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); // Close on escape + lock scroll
const [brochureUrl, setBrochureUrl] = useState<string | null>(null); useEffect(() => {
const [errorMsg, setErrorMsg] = useState(''); const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
// Mount guard for SSR/portal
useEffect(() => {
setMounted(true);
}, []);
// Close on escape + lock scroll
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formRef.current) return;
setState('submitting');
setErrorMsg('');
try {
const formData = new FormData(formRef.current);
formData.set('locale', locale);
const result = await requestBrochureAction(formData);
if (result.success && result.brochureUrl) {
setState('success');
setBrochureUrl(result.brochureUrl);
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'brochure_modal',
});
} else {
setState('error');
setErrorMsg(result.error || 'Something went wrong');
}
} catch {
setState('error');
setErrorMsg('Network error');
}
}; };
if (isOpen) {
const handleClose = () => { document.addEventListener('keydown', handleEsc);
setState('idle'); document.body.style.overflow = 'hidden';
setBrochureUrl(null); } else {
setErrorMsg(''); document.body.style.overflow = '';
onClose(); }
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = '';
}; };
}, [isOpen, onClose]);
if (!mounted || !isOpen) return null; const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formRef.current) return;
const modal = ( setState('submitting');
<div setErrorMsg('');
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
role="dialog" try {
aria-modal="true" const formData = new FormData(formRef.current);
formData.set('locale', locale);
const result = await requestBrochureAction(formData);
if (result.success) {
setState('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'brochure_modal',
});
} else {
setState('error');
setErrorMsg(result.error || 'Something went wrong');
}
} catch {
setState('error');
setErrorMsg('Network error');
}
};
const handleClose = () => {
setState('idle');
setErrorMsg('');
onClose();
};
if (!isOpen) return null;
const modal = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={handleClose}
aria-hidden="true"
/>
{/* Modal Panel */}
<div className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden">
{/* Accent bar at top */}
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
{/* Close Button */}
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors"
aria-label={t('close')}
> >
{/* Backdrop */} <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div <path
className="absolute inset-0 bg-black/70 backdrop-blur-sm" strokeLinecap="round"
onClick={handleClose} strokeLinejoin="round"
aria-hidden="true" strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/> />
</svg>
</button>
{/* Modal Panel */} <div className="p-8 pt-7">
<div className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"> {/* Icon + Header */}
{/* Accent bar at top */} <div className="mb-7">
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" /> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
<svg
{/* Close Button */} className="h-6 w-6 text-[#82ed20]"
<button fill="none"
type="button" stroke="currentColor"
onClick={handleClose} viewBox="0 0 24 24"
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors" >
aria-label={t('close')} <path
> strokeLinecap="round"
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> strokeLinejoin="round"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> strokeWidth={1.5}
</svg> d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
</button> />
</svg>
<div className="p-8 pt-7">
{/* Icon + Header */}
<div className="mb-7">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
<svg className="h-6 w-6 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
{t('title')}
</h2>
<p className="text-sm text-white/50 leading-relaxed">
{t('subtitle')}
</p>
</div>
{state === 'success' && brochureUrl ? (
<div>
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
<svg className="h-5 w-5 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-[#82ed20]">{t('successTitle')}</p>
<p className="text-xs text-white/50 mt-0.5">{t('successDesc')}</p>
</div>
</div>
<a
href={brochureUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26] font-black text-sm uppercase tracking-widest transition-colors"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{t('download')}
</a>
</div>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<div className="mb-5">
<label
htmlFor="brochure-email"
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
>
{t('emailLabel')}
</label>
<input
id="brochure-email"
name="email"
type="email"
required
autoComplete="email"
placeholder={t('emailPlaceholder')}
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
disabled={state === 'submitting'}
/>
</div>
{state === 'error' && errorMsg && (
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
)}
<button
type="submit"
disabled={state === 'submitting'}
className={cn(
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
state === 'submitting'
? 'bg-white/10 text-white/40 cursor-wait'
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
)}
>
{state === 'submitting' ? t('submitting') : t('submit')}
</button>
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
{t('privacyNote')}
</p>
</form>
)}
</div>
</div> </div>
</div> <h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
); {t('title')}
</h2>
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
</div>
return createPortal(modal, document.body); {state === 'success' ? (
<div>
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
<svg
className="h-5 w-5 text-[#82ed20]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<p className="text-sm font-bold text-[#82ed20]">
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
</p>
<p className="text-xs text-white/50 mt-0.5">
{locale === 'de'
? 'Bitte prüfen Sie Ihren Posteingang.'
: 'Please check your inbox.'}
</p>
</div>
</div>
<button
type="button"
onClick={handleClose}
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
>
{t('close')}
</button>
</div>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<div className="mb-5">
<label
htmlFor="brochure-email"
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
>
{t('emailLabel')}
</label>
<input
id="brochure-email"
name="email"
type="email"
required
autoComplete="email"
placeholder={t('emailPlaceholder')}
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
disabled={state === 'submitting'}
/>
</div>
{state === 'error' && errorMsg && (
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
)}
<button
type="submit"
disabled={state === 'submitting'}
className={cn(
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
state === 'submitting'
? 'bg-white/10 text-white/40 cursor-wait'
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
)}
>
{state === 'submitting' ? t('submitting') : t('submit')}
</button>
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
{t('privacyNote')}
</p>
</form>
)}
</div>
</div>
</div>
);
return createPortal(modal, document.body);
} }

View File

@@ -0,0 +1,145 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
Button,
} from '@react-email/components';
import * as React from 'react';
interface BrochureDeliveryEmailProps {
_email: string;
brochureUrl: string;
locale: 'en' | 'de';
}
export const BrochureDeliveryEmail = ({
_email,
brochureUrl,
locale = 'en',
}: BrochureDeliveryEmailProps) => {
const t =
locale === 'de'
? {
subject: 'Ihr KLZ Kabelkatalog',
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
button: 'Katalog herunterladen',
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
}
: {
subject: 'Your KLZ Cable Catalog',
greeting: 'Thank you for your interest in KLZ Cables.',
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
button: 'Download Catalog',
footer: 'This email was sent from klz-cables.com.',
};
return (
<Html>
<Head />
<Preview>{t.subject}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={headerSection}>
<Heading style={h1}>{t.subject}</Heading>
</Section>
<Section style={section}>
<Text style={text}>
<strong>{t.greeting}</strong>
</Text>
<Text style={text}>{t.body}</Text>
<Section style={buttonContainer}>
<Button style={button} href={brochureUrl}>
{t.button}
</Button>
</Section>
<Hr style={hr} />
</Section>
<Text style={footer}>{t.footer}</Text>
</Container>
</Body>
</Html>
);
};
export default BrochureDeliveryEmail;
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '0 0 48px',
marginBottom: '64px',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid #e6ebf1',
};
const headerSection = {
backgroundColor: '#000d26',
padding: '32px 48px',
borderBottom: '4px solid #4da612',
};
const h1 = {
color: '#ffffff',
fontSize: '24px',
fontWeight: 'bold',
margin: '0',
};
const section = {
padding: '32px 48px 0',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
textAlign: 'left' as const,
};
const buttonContainer = {
textAlign: 'center' as const,
marginTop: '32px',
marginBottom: '32px',
};
const button = {
backgroundColor: '#4da612',
borderRadius: '4px',
color: '#ffffff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '16px 32px',
};
const hr = {
borderColor: '#e6ebf1',
margin: '20px 0',
};
const footer = {
color: '#8898aa',
fontSize: '12px',
lineHeight: '16px',
textAlign: 'center' as const,
marginTop: '20px',
};

2480
data/processed/products.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
Document,
Page,
View,
Text,
Image,
StyleSheet,
Font,
} from '@react-pdf/renderer';
// Register fonts (using system fonts for now, can be customized) // Register fonts (using system fonts for now, can be customized)
Font.register({ Font.register({
@@ -18,27 +10,43 @@ Font.register({
], ],
}); });
// Industrial/technical/restrained design - STYLEGUIDE.md compliant // ─── Brand Tokens (matching brochure) ────────────────────────────────────────
const C = {
navy: '#001a4d',
navyDeep: '#000d26',
green: '#4da612',
greenLight: '#e8f5d8',
white: '#FFFFFF',
offWhite: '#f8f9fa',
gray100: '#f3f4f6',
gray200: '#e5e7eb',
gray300: '#d1d5db',
gray400: '#9ca3af',
gray600: '#4b5563',
gray900: '#111827',
};
const MARGIN = 56;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
color: '#111827', // Text Primary color: C.gray900,
lineHeight: 1.5, lineHeight: 1.5,
backgroundColor: '#FFFFFF', backgroundColor: C.white,
paddingTop: 0, paddingTop: 0,
paddingBottom: 100, paddingBottom: 80,
fontFamily: 'Helvetica', fontFamily: 'Helvetica',
}, },
// Hero-style header // Hero-style header
hero: { hero: {
backgroundColor: '#FFFFFF', backgroundColor: C.white,
paddingTop: 24, paddingTop: 24,
paddingBottom: 0, paddingBottom: 0,
paddingHorizontal: 72, paddingHorizontal: MARGIN,
marginBottom: 20, marginBottom: 20,
position: 'relative', position: 'relative',
borderBottomWidth: 0, borderBottomWidth: 0,
borderBottomColor: '#e5e7eb',
}, },
header: { header: {
@@ -49,17 +57,17 @@ const styles = StyleSheet.create({
}, },
logoText: { logoText: {
fontSize: 24, fontSize: 22,
fontWeight: 700, fontWeight: 700,
color: '#000d26', color: C.navyDeep,
letterSpacing: 1, letterSpacing: 2,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
docTitle: { docTitle: {
fontSize: 10, fontSize: 8,
fontWeight: 700, fontWeight: 700,
color: '#001a4d', color: C.green,
letterSpacing: 2, letterSpacing: 2,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
@@ -78,10 +86,10 @@ const styles = StyleSheet.create({
height: 120, height: 120,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderRadius: 8, borderRadius: 4,
borderWidth: 1, borderWidth: 1,
borderColor: '#e5e7eb', borderColor: C.gray200,
backgroundColor: '#FFFFFF', backgroundColor: C.white,
overflow: 'hidden', overflow: 'hidden',
}, },
@@ -93,7 +101,7 @@ const styles = StyleSheet.create({
productName: { productName: {
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: '#000d26', color: C.navyDeep,
marginBottom: 0, marginBottom: 0,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: -0.5, letterSpacing: -0.5,
@@ -101,7 +109,7 @@ const styles = StyleSheet.create({
productMeta: { productMeta: {
fontSize: 10, fontSize: 10,
color: '#4b5563', color: C.gray600,
fontWeight: 700, fontWeight: 700,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 1,
@@ -115,13 +123,13 @@ const styles = StyleSheet.create({
noImage: { noImage: {
fontSize: 8, fontSize: 8,
color: '#9ca3af', color: C.gray400,
textAlign: 'center', textAlign: 'center',
}, },
// Content Area // Content Area
content: { content: {
paddingHorizontal: 72, paddingHorizontal: MARGIN,
}, },
// Content sections // Content sections
@@ -130,40 +138,40 @@ const styles = StyleSheet.create({
}, },
sectionTitle: { sectionTitle: {
fontSize: 14, fontSize: 8,
fontWeight: 700, fontWeight: 700,
color: '#000d26', // Primary Dark color: C.green,
marginBottom: 8, marginBottom: 6,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: -0.2, letterSpacing: 1.5,
}, },
sectionAccent: { sectionAccent: {
width: 30, width: 30,
height: 3, height: 2,
backgroundColor: '#82ed20', // Accent Green backgroundColor: C.green,
marginBottom: 8, marginBottom: 8,
borderRadius: 1.5, borderRadius: 1,
}, },
description: { description: {
fontSize: 11, fontSize: 10,
lineHeight: 1.7, lineHeight: 1.7,
color: '#4b5563', // Text Secondary color: C.gray600,
}, },
// Technical data table // Technical data table
specsTable: { specsTable: {
marginTop: 8, marginTop: 4,
border: '1px solid #e5e7eb', borderWidth: 0,
borderRadius: 8, borderRadius: 0,
overflow: 'hidden', overflow: 'hidden',
}, },
specsTableRow: { specsTableRow: {
flexDirection: 'row', flexDirection: 'row',
borderBottomWidth: 1, borderBottomWidth: 0.5,
borderBottomColor: '#e5e7eb', borderBottomColor: C.gray200,
}, },
specsTableRowLast: { specsTableRowLast: {
@@ -172,83 +180,85 @@ const styles = StyleSheet.create({
specsTableLabelCell: { specsTableLabelCell: {
flex: 1, flex: 1,
paddingVertical: 4, paddingVertical: 5,
paddingHorizontal: 16, paddingHorizontal: 12,
backgroundColor: '#f8f9fa', backgroundColor: C.offWhite,
borderRightWidth: 1, borderRightWidth: 0.5,
borderRightColor: '#e5e7eb', borderRightColor: C.gray200,
}, },
specsTableValueCell: { specsTableValueCell: {
flex: 1, flex: 1,
paddingVertical: 4, paddingVertical: 5,
paddingHorizontal: 16, paddingHorizontal: 12,
}, },
specsTableLabelText: { specsTableLabelText: {
fontSize: 9, fontSize: 8,
fontWeight: 700, fontWeight: 700,
color: '#000d26', color: C.navyDeep,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
}, },
specsTableValueText: { specsTableValueText: {
fontSize: 10, fontSize: 9,
color: '#111827', color: C.gray900,
fontWeight: 500, fontWeight: 400,
}, },
// Categories // Categories
categories: { categories: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 8, gap: 6,
}, },
categoryTag: { categoryTag: {
backgroundColor: '#f8f9fa', backgroundColor: C.offWhite,
paddingHorizontal: 12, paddingHorizontal: 10,
paddingVertical: 6, paddingVertical: 4,
border: '1px solid #e5e7eb', borderWidth: 0.5,
borderRadius: 100, borderColor: C.gray200,
borderRadius: 3,
}, },
categoryText: { categoryText: {
fontSize: 8, fontSize: 7,
color: '#4b5563', color: C.gray600,
fontWeight: 700, fontWeight: 700,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
}, },
// Footer // Footer — matches brochure style
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: 40, bottom: 28,
left: 72, left: MARGIN,
right: 72, right: MARGIN,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingTop: 24, paddingTop: 12,
borderTop: '1px solid #e5e7eb', borderTopWidth: 2,
borderTopColor: C.green,
}, },
footerText: { footerText: {
fontSize: 8, fontSize: 7,
color: '#9ca3af', color: C.gray400,
fontWeight: 500, fontWeight: 400,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 0.8,
}, },
footerBrand: { footerBrand: {
fontSize: 10, fontSize: 9,
fontWeight: 700, fontWeight: 700,
color: '#000d26', color: C.navyDeep,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 1.5,
}, },
}); });
@@ -302,10 +312,7 @@ const getLabels = (locale: 'en' | 'de') => {
return labels[locale]; return labels[locale];
}; };
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
product,
locale,
}) => {
const labels = getLabels(locale); const labels = getLabels(locale);
return ( return (
@@ -317,9 +324,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
<View> <View>
<Text style={styles.logoText}>KLZ</Text> <Text style={styles.logoText}>KLZ</Text>
</View> </View>
<Text style={styles.docTitle}> <Text style={styles.docTitle}>{labels.productDatasheet}</Text>
{labels.productDatasheet}
</Text>
</View> </View>
<View style={styles.productRow}> <View style={styles.productRow}>
@@ -328,7 +333,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
<View style={styles.categories}> <View style={styles.categories}>
{product.categories.map((cat, index) => ( {product.categories.map((cat, index) => (
<Text key={index} style={styles.productMeta}> <Text key={index} style={styles.productMeta}>
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''} {cat.name}
{index < product.categories.length - 1 ? ' • ' : ''}
</Text> </Text>
))} ))}
</View> </View>
@@ -337,12 +343,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
</View> </View>
<View style={styles.productImageCol}> <View style={styles.productImageCol}>
{product.featuredImage ? ( {product.featuredImage ? (
<Image <Image src={product.featuredImage} style={styles.heroImage} />
src={product.featuredImage}
style={styles.heroImage}
/>
) : ( ) : (
<Text style={styles.noImage}>{labels.noImage}</Text> <Text style={styles.noImage}>{labels.noImage}</Text>
)} )}
</View> </View>
@@ -356,7 +358,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
<Text style={styles.sectionTitle}>{labels.description}</Text> <Text style={styles.sectionTitle}>{labels.description}</Text>
<View style={styles.sectionAccent} /> <View style={styles.sectionAccent} />
<Text style={styles.description}> <Text style={styles.description}>
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)} {stripHtml(
product.applicationHtml ||
product.shortDescriptionHtml ||
product.descriptionHtml,
)}
</Text> </Text>
</View> </View>
)} )}
@@ -372,17 +378,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
key={index} key={index}
style={[ style={[
styles.specsTableRow, styles.specsTableRow,
index === product.attributes.length - 1 && index === product.attributes.length - 1 && styles.specsTableRowLast,
styles.specsTableRowLast,
]} ]}
> >
<View style={styles.specsTableLabelCell}> <View style={styles.specsTableLabelCell}>
<Text style={styles.specsTableLabelText}>{attr.name}</Text> <Text style={styles.specsTableLabelText}>{attr.name}</Text>
</View> </View>
<View style={styles.specsTableValueCell}> <View style={styles.specsTableValueCell}>
<Text style={styles.specsTableValueText}> <Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
{attr.options.join(', ')}
</Text>
</View> </View>
</View> </View>
))} ))}

View File

@@ -79,7 +79,7 @@ const nextConfig = {
}, },
{ {
key: 'Strict-Transport-Security', key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload', value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
}, },
]; ];

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.

View File

@@ -0,0 +1,74 @@
import * as fs from 'fs';
import * as path from 'path';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
async function main() {
const payload = await getPayload({ config: configPromise });
const products: any[] = [];
for (const locale of ['en', 'de'] as const) {
const result = await payload.find({
collection: 'products',
locale: locale as any,
pagination: false,
});
for (const doc of result.docs) {
if (!doc.title || !doc.slug) continue;
const images: string[] = [];
if (doc.featuredImage) {
const url =
typeof doc.featuredImage === 'string'
? doc.featuredImage
: (doc.featuredImage as any).url;
if (url) images.push(url);
}
const categories = Array.isArray(doc.categories)
? doc.categories
.map((c: any) => ({ name: String(c.category || c) }))
.filter((c: any) => c.name)
: [];
const attributes: any[] = [];
if (Array.isArray((doc as any).content?.root?.children)) {
const ptBlock = (doc as any).content.root.children.find(
(n: any) => n.type === 'block' && n.fields?.blockType === 'productTabs',
);
if (ptBlock?.fields?.technicalItems) {
for (const item of ptBlock.fields.technicalItems) {
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
if (label && item.value) attributes.push({ name: label, options: [item.value] });
}
}
}
products.push({
id: doc.id,
name: doc.title,
shortDescriptionHtml: (doc as any).shortDescription || '',
descriptionHtml: '',
images,
featuredImage: images[0] || null,
sku: doc.slug,
slug: doc.slug,
translationKey: doc.slug,
locale,
categories,
attributes,
});
}
}
const outDir = path.join(process.cwd(), 'data/processed');
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
fs.writeFileSync(path.join(outDir, 'products.json'), JSON.stringify(products, null, 2));
console.log(`Exported ${products.length} products to data/processed/products.json`);
process.exit(0);
}
main();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
import { test, expect, beforeAll, afterAll } from 'vitest';
import puppeteer, { Browser, Page } from 'puppeteer';
import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
let browser: Browser;
let page: Page;
// The URL of the local Next.js server we are testing against.
// In CI, we expect the server to already be running at http://127.0.0.1:3000
const BASE_URL = process.env.TEST_URL || 'http://127.0.0.1:3000';
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (browser) {
await browser.close();
}
});
test('AutoBrochureModal should open after 5 seconds and submit email', async () => {
// Set a long timeout for the test to prevent premature failure
page = await browser.newPage();
// Clear localStorage to ensure the auto-open logic fires
await page.goto(BASE_URL);
await page.evaluate(() => {
localStorage.clear();
});
// Reload page to start the timer fresh
await page.goto(BASE_URL, { waitUntil: 'networkidle0' });
// Wait for auto-trigger (set to 5s in code, we wait up to 10s here)
const modal = await page.waitForSelector('div[role="dialog"]', { timeout: 10000 });
expect(modal).not.toBeNull();
// Ensure the email input is present inside the modal (using the placeholder to be sure)
const emailInput = await page.waitForSelector('input[name="email"]', { visible: true });
expect(emailInput).not.toBeNull();
// Fill the form matching BrochureCTA.tsx id=brochure-email
await page.type('input[name="email"]', 'test-brochure@mintel.me');
// Submit the form
await page.click('button[type="submit"]');
// Wait for the success state UI to appear
await page.waitForFunction(
() =>
document.body.innerText.includes('Successfully sent') ||
document.body.innerText.includes('Erfolgreich gesendet'),
{ timeout: 10000 },
);
// Verify localStorage was set correctly so it doesn't open again
const hasSeenModal = await page.evaluate(() => localStorage.getItem('klz_brochure_modal_seen'));
expect(hasSeenModal).toBe('true');
}, 30000);