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';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -20,439 +19,70 @@ interface Props {
|
||||
*/
|
||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||
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 [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 (
|
||||
<>
|
||||
{trigger}
|
||||
{modal}
|
||||
<div className={cn(className)}>
|
||||
<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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user