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

@@ -66,13 +66,49 @@ export async function requestBrochureAction(formData: FormData) {
logger.error('Failed to send notification', { error }); logger.error('Failed to send notification', { error });
} }
// 3. Track success // 3. Send Brochure via Email
services.analytics.track('brochure-request-success', { const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
locale,
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,
}); });
// Return the brochure URL if (emailResult.success) {
const brochureUrl = `/brochure/klz-product-catalog-${locale}.pdf`; logger.info('Brochure email sent successfully', { email });
} else {
return { success: true, brochureUrl }; 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

@@ -27,14 +27,26 @@ export default function BrochureCTA({ className, compact = false }: Props) {
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);
}, []);
function openModal() {
setOpen(true);
}
function closeModal() {
setOpen(false);
setPhase('form');
setErr('');
}
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); }; const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeModal();
};
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
return () => { return () => {
@@ -43,14 +55,6 @@ export default function BrochureCTA({ className, compact = false }: Props) {
}; };
}, [open]); }, [open]);
function openModal() { setOpen(true); }
function closeModal() {
setOpen(false);
setPhase('form');
setUrl('');
setErr('');
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!formRef.current) return; if (!formRef.current) return;
@@ -61,8 +65,7 @@ export default function BrochureCTA({ className, compact = false }: Props) {
try { try {
const res = await requestBrochureAction(fd); const res = await requestBrochureAction(fd);
if (res.success && res.brochureUrl) { if (res.success) {
setUrl(res.brochureUrl);
setPhase('success'); setPhase('success');
trackEvent(AnalyticsEvents.DOWNLOAD, { trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`, file_name: `klz-product-catalog-${locale}.pdf`,
@@ -96,19 +99,32 @@ export default function BrochureCTA({ className, compact = false }: Props) {
{/* Icon */} {/* 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"> <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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
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" /> 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> </svg>
</span> </span>
{/* Labels */} {/* Labels */}
<span className="flex-1 min-w-0"> <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="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
<span className={cn( PDF Katalog
</span>
<span
className={cn(
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200', 'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
compact ? 'text-base' : 'text-lg md:text-xl', compact ? 'text-base' : 'text-lg md:text-xl',
)}> )}
>
{t('ctaTitle')} {t('ctaTitle')}
</span> </span>
</span> </span>
@@ -124,78 +140,237 @@ export default function BrochureCTA({ className, compact = false }: Props) {
); );
// ── Modal ────────────────────────────────────────────────────────── // ── Modal ──────────────────────────────────────────────────────────
const modal = mounted && open ? createPortal( const modal =
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}> 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={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.75)',
backdropFilter: 'blur(4px)',
}}
onClick={closeModal} 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 */} {/* Green top bar */}
<div style={{ height: '3px', background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)' }} /> <div
style={{
height: '3px',
background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)',
}}
/>
{/* Close */} {/* Close */}
<button <button
type="button" type="button"
onClick={closeModal} 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' }} 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')} aria-label={t('close')}
> >
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
<div style={{ padding: '2rem' }}> <div style={{ padding: '2rem' }}>
{/* Header */} {/* Header */}
<div style={{ marginBottom: '1.5rem' }}> <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' }}> <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"> <svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} <path
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" /> 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>
</div> </div>
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1, 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')} {t('title')}
</h2> </h2>
<p style={{ margin: 0, fontSize: '0.875rem', color: 'rgba(255,255,255,0.5)', lineHeight: 1.6 }}> <p
style={{
margin: 0,
fontSize: '0.875rem',
color: 'rgba(255,255,255,0.5)',
lineHeight: 1.6,
}}
>
{t('subtitle')} {t('subtitle')}
</p> </p>
</div> </div>
{phase === 'success' ? ( {phase === 'success' ? (
<div> <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
<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 }}> style={{
<svg width="18" height="18" fill="none" stroke="#82ed20" viewBox="0 0 24 24"> display: 'flex',
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> 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> </svg>
</div> </div>
<div> <div>
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 700, color: '#82ed20' }}>{t('successTitle')}</p> <p
<p style={{ margin: '0.125rem 0 0', fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>{t('successDesc')}</p> style={{
</div> margin: 0,
</div> fontSize: '0.875rem',
<a fontWeight: 700,
href={url} color: '#82ed20',
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"> {locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} </p>
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" /> <p
</svg> style={{
{t('download')} margin: '0.125rem 0 0',
</a> 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> </div>
) : ( ) : (
<form ref={formRef} onSubmit={handleSubmit}> <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' }}> <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')} {t('emailLabel')}
</label> </label>
<input <input
@@ -205,11 +380,32 @@ export default function BrochureCTA({ className, compact = false }: Props) {
autoComplete="email" autoComplete="email"
placeholder={t('emailPlaceholder')} placeholder={t('emailPlaceholder')}
disabled={phase === 'loading'} 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' }} 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 && ( {phase === 'error' && err && (
<p style={{ margin: '0 0 0.75rem', fontSize: '0.75rem', color: '#f87171', fontWeight: 500 }}>{err}</p> <p
style={{
margin: '0 0 0.75rem',
fontSize: '0.75rem',
color: '#f87171',
fontWeight: 500,
}}
>
{err}
</p>
)} )}
<button <button
@@ -233,7 +429,15 @@ export default function BrochureCTA({ className, compact = false }: Props) {
{phase === 'loading' ? t('submitting') : t('submit')} {phase === 'loading' ? t('submitting') : t('submit')}
</button> </button>
<p style={{ margin: 0, fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)', textAlign: 'center', lineHeight: 1.6 }}> <p
style={{
margin: 0,
fontSize: '0.625rem',
color: 'rgba(255,255,255,0.25)',
textAlign: 'center',
lineHeight: 1.6,
}}
>
{t('privacyNote')} {t('privacyNote')}
</p> </p>
</form> </form>
@@ -242,7 +446,8 @@ export default function BrochureCTA({ className, compact = false }: Props) {
</div> </div>
</div>, </div>,
document.body, document.body,
) : null; )
: null;
return ( return (
<> <>

View File

@@ -18,17 +18,9 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
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 [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [brochureUrl, setBrochureUrl] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
// Mount guard for SSR/portal
useEffect(() => {
setMounted(true);
}, []);
// Close on escape + lock scroll // Close on escape + lock scroll
useEffect(() => { useEffect(() => {
const handleEsc = (e: KeyboardEvent) => { const handleEsc = (e: KeyboardEvent) => {
@@ -59,9 +51,8 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
const result = await requestBrochureAction(formData); const result = await requestBrochureAction(formData);
if (result.success && result.brochureUrl) { if (result.success) {
setState('success'); setState('success');
setBrochureUrl(result.brochureUrl);
trackEvent(AnalyticsEvents.DOWNLOAD, { trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`, file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure', file_type: 'brochure',
@@ -79,12 +70,11 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
const handleClose = () => { const handleClose = () => {
setState('idle'); setState('idle');
setBrochureUrl(null);
setErrorMsg(''); setErrorMsg('');
onClose(); onClose();
}; };
if (!mounted || !isOpen) return null; if (!isOpen) return null;
const modal = ( const modal = (
<div <div
@@ -112,7 +102,12 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
aria-label={t('close')} aria-label={t('close')}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
@@ -120,44 +115,62 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
{/* Icon + Header */} {/* Icon + Header */}
<div className="mb-7"> <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"> <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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} className="h-6 w-6 text-[#82ed20]"
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" /> 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> </svg>
</div> </div>
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2"> <h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
{t('title')} {t('title')}
</h2> </h2>
<p className="text-sm text-white/50 leading-relaxed"> <p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
{t('subtitle')}
</p>
</div> </div>
{state === 'success' && brochureUrl ? ( {state === 'success' ? (
<div> <div>
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20"> <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"> <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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> 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> </svg>
</div> </div>
<div> <div>
<p className="text-sm font-bold text-[#82ed20]">{t('successTitle')}</p> <p className="text-sm font-bold text-[#82ed20]">
<p className="text-xs text-white/50 mt-0.5">{t('successDesc')}</p> {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>
</div> </div>
<a <button
href={brochureUrl} type="button"
target="_blank" onClick={handleClose}
rel="noopener noreferrer" 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"
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"> {t('close')}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} </button>
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> </div>
) : ( ) : (
<form ref={formRef} onSubmit={handleSubmit}> <form ref={formRef} onSubmit={handleSubmit}>

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);