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
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:
@@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { config } from '@/lib/config';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
@@ -61,6 +59,7 @@ export const viewport: Viewport = {
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
import AutoBrochureModal from '@/components/AutoBrochureModal';
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -77,7 +76,7 @@ export default async function Layout(props: {
|
||||
let messages: Record<string, any> = {};
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
messages = {};
|
||||
}
|
||||
|
||||
@@ -161,6 +160,8 @@ export default async function Layout(props: {
|
||||
|
||||
<AnalyticsShell />
|
||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||
|
||||
<AutoBrochureModal />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,76 +3,112 @@
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
message: `Brochure download request (${locale})`,
|
||||
type: 'brochure_download' as any,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||
} catch (error) {
|
||||
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||
}
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
message: `Brochure download request (${locale})`,
|
||||
type: 'brochure_download' as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Return the brochure URL
|
||||
const brochureUrl = `/brochure/klz-product-catalog-${locale}.pdf`;
|
||||
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||
} catch (error) {
|
||||
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||
}
|
||||
|
||||
return { success: true, brochureUrl };
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Send Brochure via Email
|
||||
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
|
||||
|
||||
try {
|
||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||
const { render } = await import('@mintel/mail');
|
||||
const React = await import('react');
|
||||
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||
|
||||
const html = await render(
|
||||
React.createElement(BrochureDeliveryEmail, {
|
||||
email,
|
||||
brochureUrl,
|
||||
locale: locale as 'en' | 'de',
|
||||
}),
|
||||
);
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
|
||||
html,
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Brochure email sent successfully', { email });
|
||||
} else {
|
||||
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||
action: 'requestBrochureAction_email',
|
||||
email,
|
||||
});
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Exception while sending brochure email', { error });
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
|
||||
// 4. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
delivery_method: 'email',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
28
components/AutoBrochureModal.tsx
Normal file
28
components/AutoBrochureModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
export default function AutoBrochureModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already seen or interacted with the modal
|
||||
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
|
||||
|
||||
if (!hasSeenModal) {
|
||||
// Auto-open after 5 seconds to not interrupt immediate page load
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
// Mark as seen so it doesn't bother them again on next page load
|
||||
localStorage.setItem('klz_brochure_modal_seen', 'true');
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,235 +19,440 @@ interface Props {
|
||||
* No direct download link is exposed anywhere.
|
||||
*/
|
||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
|
||||
const [url, setUrl] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [phase, setPhase] = useState<'form' | 'loading' | 'success' | 'error'>('form');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
function openModal() {
|
||||
setOpen(true);
|
||||
}
|
||||
function closeModal() {
|
||||
setOpen(false);
|
||||
setPhase('form');
|
||||
setErr('');
|
||||
}
|
||||
|
||||
function openModal() { setOpen(true); }
|
||||
function closeModal() {
|
||||
setOpen(false);
|
||||
setPhase('form');
|
||||
setUrl('');
|
||||
setErr('');
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
setPhase('loading');
|
||||
|
||||
const fd = new FormData(formRef.current);
|
||||
fd.set('locale', locale);
|
||||
|
||||
try {
|
||||
const res = await requestBrochureAction(fd);
|
||||
if (res.success) {
|
||||
setPhase('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setErr(res.error || 'Error');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error');
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
setPhase('loading');
|
||||
// ── Trigger Button ─────────────────────────────────────────────────
|
||||
const trigger = (
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
|
||||
const fd = new FormData(formRef.current);
|
||||
fd.set('locale', locale);
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
try {
|
||||
const res = await requestBrochureAction(fd);
|
||||
if (res.success && res.brochureUrl) {
|
||||
setUrl(res.brochureUrl);
|
||||
setPhase('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setErr(res.error || 'Error');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error');
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
|
||||
PDF Katalog
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}
|
||||
>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
// ── Trigger Button ─────────────────────────────────────────────────
|
||||
const trigger = (
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">PDF Katalog</span>
|
||||
<span className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────────
|
||||
const modal = mounted && open ? createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
|
||||
// ── Modal ──────────────────────────────────────────────────────────
|
||||
const modal =
|
||||
mounted && open
|
||||
? createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={closeModal}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: '26rem', borderRadius: '1.5rem', background: '#000d26', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 40px 80px rgba(0,0,0,0.6)', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
width: '100%',
|
||||
maxWidth: '26rem',
|
||||
borderRadius: '1.5rem',
|
||||
background: '#000d26',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
boxShadow: '0 40px 80px rgba(0,0,0,0.6)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Green top bar */}
|
||||
<div
|
||||
style={{
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Green top bar */}
|
||||
<div style={{ height: '3px', background: 'linear-gradient(90deg, #82ed20, #5cb516, #82ed20)' }} />
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '2rem',
|
||||
height: '2rem',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2rem', height: '2rem', borderRadius: '50%', background: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.4)', border: 'none', cursor: 'pointer' }}
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<div style={{ padding: '2rem' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '2.75rem',
|
||||
height: '2.75rem',
|
||||
borderRadius: '0.75rem',
|
||||
background: 'rgba(130,237,32,0.1)',
|
||||
border: '1px solid rgba(130,237,32,0.2)',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style={{ padding: '2rem' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.75rem', height: '2.75rem', borderRadius: '0.75rem', background: 'rgba(130,237,32,0.1)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
|
||||
<svg width="20" height="20" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', color: 'rgba(255,255,255,0.5)', lineHeight: 1.6 }}>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{phase === 'success' ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem', borderRadius: '1rem', background: 'rgba(130,237,32,0.08)', border: '1px solid rgba(130,237,32,0.2)', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '2.25rem', height: '2.25rem', borderRadius: '0.625rem', background: 'rgba(130,237,32,0.15)', flexShrink: 0 }}>
|
||||
<svg width="18" height="18" fill="none" stroke="#82ed20" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 700, color: '#82ed20' }}>{t('successTitle')}</p>
|
||||
<p style={{ margin: '0.125rem 0 0', fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>{t('successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%', padding: '1rem', borderRadius: '1rem', background: '#82ed20', color: '#000d26', fontWeight: 900, fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.1em', textDecoration: 'none' }}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<label style={{ display: 'block', fontSize: '0.625rem', fontWeight: 900, textTransform: 'uppercase', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)', marginBottom: '0.5rem' }}>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
style={{ width: '100%', padding: '0.875rem 1rem', borderRadius: '0.75rem', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.875rem', fontWeight: 500, outline: 'none', boxSizing: 'border-box', marginBottom: '0.75rem' }}
|
||||
/>
|
||||
|
||||
{phase === 'error' && err && (
|
||||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.75rem', color: '#f87171', fontWeight: 500 }}>{err}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
|
||||
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
border: 'none',
|
||||
cursor: phase === 'loading' ? 'wait' : 'pointer',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 900,
|
||||
color: '#fff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '-0.03em',
|
||||
lineHeight: 1,
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.875rem',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
{phase === 'success' ? (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: 'rgba(130,237,32,0.08)',
|
||||
border: '1px solid rgba(130,237,32,0.2)',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '2.25rem',
|
||||
height: '2.25rem',
|
||||
borderRadius: '0.625rem',
|
||||
background: 'rgba(130,237,32,0.15)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
fill="none"
|
||||
stroke="#82ed20"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 700,
|
||||
color: '#82ed20',
|
||||
}}
|
||||
>
|
||||
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.125rem 0 0',
|
||||
fontSize: '0.75rem',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
}}
|
||||
>
|
||||
{locale === 'de'
|
||||
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||
: 'Please check your inbox.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 900,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.2em',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.875rem 1rem',
|
||||
borderRadius: '0.75rem',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
|
||||
{phase === 'error' && err && (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 0.75rem',
|
||||
fontSize: '0.75rem',
|
||||
color: '#f87171',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: phase === 'loading' ? 'rgba(255,255,255,0.1)' : '#82ed20',
|
||||
color: phase === 'loading' ? 'rgba(255,255,255,0.4)' : '#000d26',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
border: 'none',
|
||||
cursor: phase === 'loading' ? 'wait' : 'pointer',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.625rem',
|
||||
color: 'rgba(255,255,255,0.25)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,203 +9,216 @@ import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface BrochureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [brochureUrl, setBrochureUrl] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Mount guard for SSR/portal
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Close on escape + lock scroll
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData(formRef.current);
|
||||
formData.set('locale', locale);
|
||||
|
||||
const result = await requestBrochureAction(formData);
|
||||
|
||||
if (result.success && result.brochureUrl) {
|
||||
setState('success');
|
||||
setBrochureUrl(result.brochureUrl);
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setState('error');
|
||||
setErrorMsg(result.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMsg('Network error');
|
||||
}
|
||||
// Close on escape + lock scroll
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState('idle');
|
||||
setBrochureUrl(null);
|
||||
setErrorMsg('');
|
||||
onClose();
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!mounted || !isOpen) return null;
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData(formRef.current);
|
||||
formData.set('locale', locale);
|
||||
|
||||
const result = await requestBrochureAction(formData);
|
||||
|
||||
if (result.success) {
|
||||
setState('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setState('error');
|
||||
setErrorMsg(result.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMsg('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState('idle');
|
||||
setErrorMsg('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden">
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden">
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg className="h-6 w-6 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state === 'success' && brochureUrl ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg className="h-5 w-5 text-[#82ed20]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">{t('successTitle')}</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">{t('successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={brochureUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26] font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
{state === 'success' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">
|
||||
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">
|
||||
{locale === 'de'
|
||||
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||
: 'Please check your inbox.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
|
||||
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
} from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
interface BrochureDeliveryEmailProps {
|
||||
_email: string;
|
||||
brochureUrl: string;
|
||||
locale: 'en' | 'de';
|
||||
}
|
||||
|
||||
export const BrochureDeliveryEmail = ({
|
||||
_email,
|
||||
brochureUrl,
|
||||
locale = 'en',
|
||||
}: BrochureDeliveryEmailProps) => {
|
||||
const t =
|
||||
locale === 'de'
|
||||
? {
|
||||
subject: 'Ihr KLZ Kabelkatalog',
|
||||
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
|
||||
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||
button: 'Katalog herunterladen',
|
||||
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
|
||||
}
|
||||
: {
|
||||
subject: 'Your KLZ Cable Catalog',
|
||||
greeting: 'Thank you for your interest in KLZ Cables.',
|
||||
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||
button: 'Download Catalog',
|
||||
footer: 'This email was sent from klz-cables.com.',
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{t.subject}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={headerSection}>
|
||||
<Heading style={h1}>{t.subject}</Heading>
|
||||
</Section>
|
||||
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>{t.greeting}</strong>
|
||||
</Text>
|
||||
<Text style={text}>{t.body}</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={brochureUrl}>
|
||||
{t.button}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr style={hr} />
|
||||
</Section>
|
||||
<Text style={footer}>{t.footer}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrochureDeliveryEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '0 0 48px',
|
||||
marginBottom: '64px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e6ebf1',
|
||||
};
|
||||
|
||||
const headerSection = {
|
||||
backgroundColor: '#000d26',
|
||||
padding: '32px 48px',
|
||||
borderBottom: '4px solid #4da612',
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: '#ffffff',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0',
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: '32px 48px 0',
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
textAlign: 'left' as const,
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '32px',
|
||||
marginBottom: '32px',
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#4da612',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '16px 32px',
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e6ebf1',
|
||||
margin: '20px 0',
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: '#8898aa',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '20px',
|
||||
};
|
||||
2480
data/processed/products.json
Normal file
2480
data/processed/products.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Font,
|
||||
} from '@react-pdf/renderer';
|
||||
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
Font.register({
|
||||
@@ -18,27 +10,43 @@ Font.register({
|
||||
],
|
||||
});
|
||||
|
||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
||||
// ─── Brand Tokens (matching brochure) ────────────────────────────────────────
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
green: '#4da612',
|
||||
greenLight: '#e8f5d8',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const MARGIN = 56;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
color: '#111827', // Text Primary
|
||||
color: C.gray900,
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 100,
|
||||
paddingBottom: 80,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 0,
|
||||
paddingHorizontal: 72,
|
||||
paddingHorizontal: MARGIN,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
header: {
|
||||
@@ -49,17 +57,17 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
logoText: {
|
||||
fontSize: 24,
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
letterSpacing: 1,
|
||||
color: C.navyDeep,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
docTitle: {
|
||||
fontSize: 10,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#001a4d',
|
||||
color: C.green,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
@@ -78,10 +86,10 @@ const styles = StyleSheet.create({
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderColor: C.gray200,
|
||||
backgroundColor: C.white,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -93,7 +101,7 @@ const styles = StyleSheet.create({
|
||||
productName: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
marginBottom: 0,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
@@ -101,7 +109,7 @@ const styles = StyleSheet.create({
|
||||
|
||||
productMeta: {
|
||||
fontSize: 10,
|
||||
color: '#4b5563',
|
||||
color: C.gray600,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
@@ -115,13 +123,13 @@ const styles = StyleSheet.create({
|
||||
|
||||
noImage: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
color: C.gray400,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: 72,
|
||||
paddingHorizontal: MARGIN,
|
||||
},
|
||||
|
||||
// Content sections
|
||||
@@ -130,40 +138,40 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#000d26', // Primary Dark
|
||||
marginBottom: 8,
|
||||
color: C.green,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.2,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
|
||||
sectionAccent: {
|
||||
width: 30,
|
||||
height: 3,
|
||||
backgroundColor: '#82ed20', // Accent Green
|
||||
height: 2,
|
||||
backgroundColor: C.green,
|
||||
marginBottom: 8,
|
||||
borderRadius: 1.5,
|
||||
borderRadius: 1,
|
||||
},
|
||||
|
||||
description: {
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.7,
|
||||
color: '#4b5563', // Text Secondary
|
||||
color: C.gray600,
|
||||
},
|
||||
|
||||
// Technical data table
|
||||
specsTable: {
|
||||
marginTop: 8,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
specsTableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: C.gray200,
|
||||
},
|
||||
|
||||
specsTableRowLast: {
|
||||
@@ -172,83 +180,85 @@ const styles = StyleSheet.create({
|
||||
|
||||
specsTableLabelCell: {
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#e5e7eb',
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: C.offWhite,
|
||||
borderRightWidth: 0.5,
|
||||
borderRightColor: C.gray200,
|
||||
},
|
||||
|
||||
specsTableValueCell: {
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
|
||||
specsTableLabelText: {
|
||||
fontSize: 9,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
specsTableValueText: {
|
||||
fontSize: 10,
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
fontSize: 9,
|
||||
color: C.gray900,
|
||||
fontWeight: 400,
|
||||
},
|
||||
|
||||
// Categories
|
||||
categories: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
gap: 6,
|
||||
},
|
||||
|
||||
categoryTag: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 100,
|
||||
backgroundColor: C.offWhite,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderWidth: 0.5,
|
||||
borderColor: C.gray200,
|
||||
borderRadius: 3,
|
||||
},
|
||||
|
||||
categoryText: {
|
||||
fontSize: 8,
|
||||
color: '#4b5563',
|
||||
fontSize: 7,
|
||||
color: C.gray600,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Footer
|
||||
// Footer — matches brochure style
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
left: 72,
|
||||
right: 72,
|
||||
bottom: 28,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: C.green,
|
||||
},
|
||||
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
fontWeight: 500,
|
||||
fontSize: 7,
|
||||
color: C.gray400,
|
||||
fontWeight: 400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 10,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#000d26',
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -302,10 +312,7 @@ const getLabels = (locale: 'en' | 'de') => {
|
||||
return labels[locale];
|
||||
};
|
||||
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
product,
|
||||
locale,
|
||||
}) => {
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||
const labels = getLabels(locale);
|
||||
|
||||
return (
|
||||
@@ -317,9 +324,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>
|
||||
{labels.productDatasheet}
|
||||
</Text>
|
||||
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productRow}>
|
||||
@@ -328,7 +333,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<Text key={index} style={styles.productMeta}>
|
||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
{cat.name}
|
||||
{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
@@ -337,12 +343,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
</View>
|
||||
<View style={styles.productImageCol}>
|
||||
{product.featuredImage ? (
|
||||
<Image
|
||||
src={product.featuredImage}
|
||||
style={styles.heroImage}
|
||||
/>
|
||||
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||
) : (
|
||||
|
||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -356,7 +358,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
{stripHtml(
|
||||
product.applicationHtml ||
|
||||
product.shortDescriptionHtml ||
|
||||
product.descriptionHtml,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -372,17 +378,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 &&
|
||||
styles.specsTableRowLast,
|
||||
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -79,7 +79,7 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
BIN
public/datasheets/h1z2z2-k-de.pdf
Normal file
BIN
public/datasheets/h1z2z2-k-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/h1z2z2-k-en.pdf
Normal file
BIN
public/datasheets/h1z2z2-k-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2x2y-de.pdf
Normal file
BIN
public/datasheets/n2x2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2x2y-en.pdf
Normal file
BIN
public/datasheets/n2x2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfk2y-de.pdf
Normal file
BIN
public/datasheets/n2xfk2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfk2y-en.pdf
Normal file
BIN
public/datasheets/n2xfk2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfkld2y-de.pdf
Normal file
BIN
public/datasheets/n2xfkld2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xfkld2y-en.pdf
Normal file
BIN
public/datasheets/n2xfkld2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xs2y-de.pdf
Normal file
BIN
public/datasheets/n2xs2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xs2y-en.pdf
Normal file
BIN
public/datasheets/n2xs2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsf2y-de.pdf
Normal file
BIN
public/datasheets/n2xsf2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsf2y-en.pdf
Normal file
BIN
public/datasheets/n2xsf2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-hv-de.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-hv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-hv-en.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-hv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-mv-de.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-mv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsfl2y-mv-en.pdf
Normal file
BIN
public/datasheets/n2xsfl2y-mv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsy-de.pdf
Normal file
BIN
public/datasheets/n2xsy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xsy-en.pdf
Normal file
BIN
public/datasheets/n2xsy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xy-de.pdf
Normal file
BIN
public/datasheets/n2xy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/n2xy-en.pdf
Normal file
BIN
public/datasheets/n2xy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2x2y-de.pdf
Normal file
BIN
public/datasheets/na2x2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2x2y-en.pdf
Normal file
BIN
public/datasheets/na2x2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfk2y-de.pdf
Normal file
BIN
public/datasheets/na2xfk2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfk2y-en.pdf
Normal file
BIN
public/datasheets/na2xfk2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfkld2y-de.pdf
Normal file
BIN
public/datasheets/na2xfkld2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xfkld2y-en.pdf
Normal file
BIN
public/datasheets/na2xfkld2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xs2y-de.pdf
Normal file
BIN
public/datasheets/na2xs2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xs2y-en.pdf
Normal file
BIN
public/datasheets/na2xs2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsf2y-de.pdf
Normal file
BIN
public/datasheets/na2xsf2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsf2y-en.pdf
Normal file
BIN
public/datasheets/na2xsf2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-hv-de.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-hv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-hv-en.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-hv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-mv-de.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-mv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsfl2y-mv-en.pdf
Normal file
BIN
public/datasheets/na2xsfl2y-mv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsy-de.pdf
Normal file
BIN
public/datasheets/na2xsy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xsy-en.pdf
Normal file
BIN
public/datasheets/na2xsy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xy-de.pdf
Normal file
BIN
public/datasheets/na2xy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/na2xy-en.pdf
Normal file
BIN
public/datasheets/na2xy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nay2y-de.pdf
Normal file
BIN
public/datasheets/nay2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nay2y-en.pdf
Normal file
BIN
public/datasheets/nay2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/naycwy-de.pdf
Normal file
BIN
public/datasheets/naycwy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/naycwy-en.pdf
Normal file
BIN
public/datasheets/naycwy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nayy-de.pdf
Normal file
BIN
public/datasheets/nayy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nayy-en.pdf
Normal file
BIN
public/datasheets/nayy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/ny2y-de.pdf
Normal file
BIN
public/datasheets/ny2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/ny2y-en.pdf
Normal file
BIN
public/datasheets/ny2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nycwy-de.pdf
Normal file
BIN
public/datasheets/nycwy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nycwy-en.pdf
Normal file
BIN
public/datasheets/nycwy-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nyy-de.pdf
Normal file
BIN
public/datasheets/nyy-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/nyy-en.pdf
Normal file
BIN
public/datasheets/nyy-en.pdf
Normal file
Binary file not shown.
74
scripts/export-legacy-products-json.ts
Normal file
74
scripts/export-legacy-products-json.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
async function main() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const products: any[] = [];
|
||||
|
||||
for (const locale of ['en', 'de'] as const) {
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
locale: locale as any,
|
||||
pagination: false,
|
||||
});
|
||||
|
||||
for (const doc of result.docs) {
|
||||
if (!doc.title || !doc.slug) continue;
|
||||
|
||||
const images: string[] = [];
|
||||
if (doc.featuredImage) {
|
||||
const url =
|
||||
typeof doc.featuredImage === 'string'
|
||||
? doc.featuredImage
|
||||
: (doc.featuredImage as any).url;
|
||||
if (url) images.push(url);
|
||||
}
|
||||
|
||||
const categories = Array.isArray(doc.categories)
|
||||
? doc.categories
|
||||
.map((c: any) => ({ name: String(c.category || c) }))
|
||||
.filter((c: any) => c.name)
|
||||
: [];
|
||||
|
||||
const attributes: any[] = [];
|
||||
if (Array.isArray((doc as any).content?.root?.children)) {
|
||||
const ptBlock = (doc as any).content.root.children.find(
|
||||
(n: any) => n.type === 'block' && n.fields?.blockType === 'productTabs',
|
||||
);
|
||||
if (ptBlock?.fields?.technicalItems) {
|
||||
for (const item of ptBlock.fields.technicalItems) {
|
||||
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
|
||||
if (label && item.value) attributes.push({ name: label, options: [item.value] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
products.push({
|
||||
id: doc.id,
|
||||
name: doc.title,
|
||||
shortDescriptionHtml: (doc as any).shortDescription || '',
|
||||
descriptionHtml: '',
|
||||
images,
|
||||
featuredImage: images[0] || null,
|
||||
sku: doc.slug,
|
||||
slug: doc.slug,
|
||||
translationKey: doc.slug,
|
||||
locale,
|
||||
categories,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const outDir = path.join(process.cwd(), 'data/processed');
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(path.join(outDir, 'products.json'), JSON.stringify(products, null, 2));
|
||||
console.log(`Exported ${products.length} products to data/processed/products.json`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
File diff suppressed because it is too large
Load Diff
65
tests/brochure-modal.test.ts
Normal file
65
tests/brochure-modal.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { test, expect, beforeAll, afterAll } from 'vitest';
|
||||
import puppeteer, { Browser, Page } from 'puppeteer';
|
||||
import { createServer } from 'http';
|
||||
import { parse } from 'url';
|
||||
import next from 'next';
|
||||
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
// The URL of the local Next.js server we are testing against.
|
||||
// In CI, we expect the server to already be running at http://127.0.0.1:3000
|
||||
const BASE_URL = process.env.TEST_URL || 'http://127.0.0.1:3000';
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('AutoBrochureModal should open after 5 seconds and submit email', async () => {
|
||||
// Set a long timeout for the test to prevent premature failure
|
||||
page = await browser.newPage();
|
||||
|
||||
// Clear localStorage to ensure the auto-open logic fires
|
||||
await page.goto(BASE_URL);
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// Reload page to start the timer fresh
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle0' });
|
||||
|
||||
// Wait for auto-trigger (set to 5s in code, we wait up to 10s here)
|
||||
const modal = await page.waitForSelector('div[role="dialog"]', { timeout: 10000 });
|
||||
expect(modal).not.toBeNull();
|
||||
|
||||
// Ensure the email input is present inside the modal (using the placeholder to be sure)
|
||||
const emailInput = await page.waitForSelector('input[name="email"]', { visible: true });
|
||||
expect(emailInput).not.toBeNull();
|
||||
|
||||
// Fill the form matching BrochureCTA.tsx id=brochure-email
|
||||
await page.type('input[name="email"]', 'test-brochure@mintel.me');
|
||||
|
||||
// Submit the form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for the success state UI to appear
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.body.innerText.includes('Successfully sent') ||
|
||||
document.body.innerText.includes('Erfolgreich gesendet'),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
// Verify localStorage was set correctly so it doesn't open again
|
||||
const hasSeenModal = await page.evaluate(() => localStorage.getItem('klz_brochure_modal_seen'));
|
||||
expect(hasSeenModal).toBe('true');
|
||||
}, 30000);
|
||||
Reference in New Issue
Block a user