fix(ui): Re-architecture of CTA Modal to eliminate hydration mismatch overhead, enhance modal accessibility with focus trap and scroll lock, and add a dedicated inline form component for the footer. Fixed Payload CMS collection schema to register brochure downlod types.
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 1s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 2m3s
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 1s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 2m3s
This commit is contained in:
@@ -1,12 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
import dynamic from 'next/dynamic';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -20,439 +19,70 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||||
const t = useTranslations('Brochure');
|
const t = useTranslations('Brochure');
|
||||||
const locale = useLocale();
|
|
||||||
const { trackEvent } = useAnalytics();
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function openModal() {
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
function closeModal() {
|
|
||||||
setOpen(false);
|
|
||||||
setPhase('form');
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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" />
|
|
||||||
|
|
||||||
{/* 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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{trigger}
|
<div className={cn(className)}>
|
||||||
{modal}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
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" />
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,22 +18,49 @@ 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 modalRef = useRef<HTMLDivElement>(null);
|
||||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
// Close on escape + lock scroll
|
// Close on escape + lock scroll + focus trap
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
if (!isOpen) return;
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
};
|
// Auto-focus input when opened
|
||||||
if (isOpen) {
|
const firstInput = document.getElementById('brochure-email');
|
||||||
document.addEventListener('keydown', handleEsc);
|
if (firstInput) {
|
||||||
document.body.style.overflow = 'hidden';
|
setTimeout(() => firstInput.focus(), 50);
|
||||||
} else {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && modalRef.current) {
|
||||||
|
const focusable = modalRef.current.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
) as NodeListOf<HTMLElement>;
|
||||||
|
|
||||||
|
if (focusable.length > 0) {
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
last.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
first.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
// Strict overflow lock on mobile as well
|
||||||
|
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleEsc);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
@@ -90,7 +117,10 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal Panel */}
|
{/* 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">
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
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 */}
|
{/* Accent bar at top */}
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||||
|
|
||||||
@@ -98,7 +128,7 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
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"
|
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 cursor-pointer"
|
||||||
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">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
import BrochureCTA from './BrochureCTA';
|
import FooterBrochureForm from './FooterBrochureForm';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const t = useTranslations('Footer');
|
const t = useTranslations('Footer');
|
||||||
@@ -188,9 +188,6 @@ export default function Footer() {
|
|||||||
{navT('contact')}
|
{navT('contact')}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li className="pt-2">
|
|
||||||
<BrochureCTA compact className="opacity-80 hover:opacity-100 transition-opacity" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -247,6 +244,10 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-12 md:mb-16">
|
||||||
|
<FooterBrochureForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||||
<p>{t('copyright', { year: currentYear })}</p>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
|
|||||||
124
components/FooterBrochureForm.tsx
Normal file
124
components/FooterBrochureForm.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FooterBrochureForm({ className }: Props) {
|
||||||
|
const t = useTranslations('Brochure');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
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: 'footer_inline',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setErr(res.error || 'Error');
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setErr('Network error');
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'success') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-bold mb-1">
|
||||||
|
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
{locale === 'de'
|
||||||
|
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
|
||||||
|
: 'We have just sent the catalog to your email.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 max-w-xl">
|
||||||
|
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
|
||||||
|
{t('ctaTitle')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
|
||||||
|
>
|
||||||
|
<div className="relative w-full sm:w-64">
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder={t('emailPlaceholder')}
|
||||||
|
disabled={phase === 'loading'}
|
||||||
|
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={phase === 'loading'}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
|
||||||
|
phase === 'loading'
|
||||||
|
? 'bg-white/10 text-white/40 cursor-wait'
|
||||||
|
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{phase === 'error' && err && (
|
||||||
|
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,7 +87,9 @@ export interface Config {
|
|||||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||||
pages: PagesSelect<false> | PagesSelect<true>;
|
pages: PagesSelect<false> | PagesSelect<true>;
|
||||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents':
|
||||||
|
| PayloadLockedDocumentsSelect<false>
|
||||||
|
| PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
};
|
};
|
||||||
@@ -249,7 +251,7 @@ export interface FormSubmission {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
type: 'contact' | 'product_quote';
|
type: 'contact' | 'product_quote' | 'brochure_download';
|
||||||
/**
|
/**
|
||||||
* The specific KLZ product the user requested a quote for.
|
* The specific KLZ product the user requested a quote for.
|
||||||
*/
|
*/
|
||||||
@@ -957,7 +959,6 @@ export interface Auth {
|
|||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
declare module 'payload' {
|
declare module 'payload' {
|
||||||
export interface GeneratedTypes extends Config {}
|
export interface GeneratedTypes extends Config {}
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@ export const FormSubmissions: CollectionConfig = {
|
|||||||
options: [
|
options: [
|
||||||
{ label: 'General Contact', value: 'contact' },
|
{ label: 'General Contact', value: 'contact' },
|
||||||
{ label: 'Product Quote', value: 'product_quote' },
|
{ label: 'Product Quote', value: 'product_quote' },
|
||||||
|
{ label: 'Brochure Download', value: 'brochure_download' },
|
||||||
],
|
],
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
|
|||||||
@@ -42,24 +42,29 @@ test('AutoBrochureModal should open after 5 seconds and submit email', async ()
|
|||||||
expect(modal).not.toBeNull();
|
expect(modal).not.toBeNull();
|
||||||
|
|
||||||
// Ensure the email input is present inside the modal (using the placeholder to be sure)
|
// 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 });
|
const emailInput = await page.waitForSelector('#brochure-email', { visible: true });
|
||||||
expect(emailInput).not.toBeNull();
|
expect(emailInput).not.toBeNull();
|
||||||
|
|
||||||
// Fill the form matching BrochureCTA.tsx id=brochure-email
|
// Fill the form inside the modal
|
||||||
await page.type('input[name="email"]', 'test-brochure@mintel.me');
|
await page.type('#brochure-email', 'test-brochure@mintel.me');
|
||||||
|
|
||||||
// Submit the form
|
// Submit the form inside the modal
|
||||||
await page.click('button[type="submit"]');
|
await page.click('div[role="dialog"] button[type="submit"]');
|
||||||
|
|
||||||
// Wait for the success state UI to appear
|
// Wait for the success state UI to appear inside the modal
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() =>
|
() => {
|
||||||
document.body.innerText.includes('Successfully sent') ||
|
const modal = document.querySelector('div[role="dialog"]');
|
||||||
document.body.innerText.includes('Erfolgreich gesendet'),
|
return (
|
||||||
{ timeout: 10000 },
|
modal &&
|
||||||
|
(modal.innerHTML.includes('Successfully sent') ||
|
||||||
|
modal.innerHTML.includes('Erfolgreich gesendet'))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 25000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify localStorage was set correctly so it doesn't open again
|
// Verify localStorage was set correctly so it doesn't open again
|
||||||
const hasSeenModal = await page.evaluate(() => localStorage.getItem('klz_brochure_modal_seen'));
|
const hasSeenModal = await page.evaluate(() => localStorage.getItem('klz_brochure_modal_seen'));
|
||||||
expect(hasSeenModal).toBe('true');
|
expect(hasSeenModal).toBe('true');
|
||||||
}, 30000);
|
}, 60000);
|
||||||
|
|||||||
Reference in New Issue
Block a user