From 34bb91c04bcea04e4dd182fc9ea88e7fcaf5c916 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 3 Mar 2026 12:23:15 +0100 Subject: [PATCH] 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. --- components/BrochureCTA.tsx | 500 +++------------------ components/BrochureModal.tsx | 54 ++- components/Footer.tsx | 9 +- components/FooterBrochureForm.tsx | 124 +++++ payload-types.ts | 9 +- src/payload/collections/FormSubmissions.ts | 1 + tests/brochure-modal.test.ts | 27 +- 7 files changed, 258 insertions(+), 466 deletions(-) create mode 100644 components/FooterBrochureForm.tsx diff --git a/components/BrochureCTA.tsx b/components/BrochureCTA.tsx index f457d276..da165a9e 100644 --- a/components/BrochureCTA.tsx +++ b/components/BrochureCTA.tsx @@ -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(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 = ( -
- -
- ); - - // ── Modal ────────────────────────────────────────────────────────── - const modal = - mounted && open - ? createPortal( -
- {/* Backdrop */} -
- - {/* Panel */} -
- {/* Green top bar */} -
- - {/* Close */} - - -
- {/* Header */} -
-
- - - -
-

- {t('title')} -

-

- {t('subtitle')} -

-
- - {phase === 'success' ? ( -
-
-
- - - -
-
-

- {locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'} -

-

- {locale === 'de' - ? 'Bitte prüfen Sie Ihren Posteingang.' - : 'Please check your inbox.'} -

-
-
- -
- ) : ( -
- - - - {phase === 'error' && err && ( -

- {err} -

- )} - - - -

- {t('privacyNote')} -

-
- )} -
-
-
, - document.body, - ) - : null; return ( <> - {trigger} - {modal} +
+ +
+ + setOpen(false)} /> ); } diff --git a/components/BrochureModal.tsx b/components/BrochureModal.tsx index 266dc754..c71dd604 100644 --- a/components/BrochureModal.tsx +++ b/components/BrochureModal.tsx @@ -18,22 +18,49 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) { const locale = useLocale(); const { trackEvent } = useAnalytics(); const formRef = useRef(null); + const modalRef = useRef(null); const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); const [errorMsg, setErrorMsg] = useState(''); - // Close on escape + lock scroll + // Close on escape + lock scroll + focus trap 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 = ''; + if (!isOpen) return; + + // Auto-focus input when opened + const firstInput = document.getElementById('brochure-email'); + if (firstInput) { + setTimeout(() => firstInput.focus(), 50); } + + 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; + + 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 () => { - document.removeEventListener('keydown', handleEsc); + document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; }; }, [isOpen, onClose]); @@ -90,7 +117,10 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) { /> {/* Modal Panel */} -
+
{/* Accent bar at top */}
@@ -98,7 +128,7 @@ export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
@@ -247,6 +244,10 @@ export default function Footer() {
+
+ +
+

{t('copyright', { year: currentYear })}

diff --git a/components/FooterBrochureForm.tsx b/components/FooterBrochureForm.tsx new file mode 100644 index 00000000..85b1ddbe --- /dev/null +++ b/components/FooterBrochureForm.tsx @@ -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(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 ( +
+
+ + + +
+
+

+ {locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'} +

+

+ {locale === 'de' + ? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.' + : 'We have just sent the catalog to your email.'} +

+
+
+ ); + } + + return ( +
+
+

+ {t('ctaTitle')} +

+

{t('subtitle')}

+
+ +
+
+ +
+ +
+ {phase === 'error' && err && ( +
{err}
+ )} +
+ ); +} diff --git a/payload-types.ts b/payload-types.ts index c58d8e40..a4ea02b4 100644 --- a/payload-types.ts +++ b/payload-types.ts @@ -87,7 +87,9 @@ export interface Config { products: ProductsSelect | ProductsSelect; pages: PagesSelect | PagesSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; - 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-locked-documents': + | PayloadLockedDocumentsSelect + | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; @@ -249,7 +251,7 @@ export interface FormSubmission { id: number; name: string; email: string; - type: 'contact' | 'product_quote'; + type: 'contact' | 'product_quote' | 'brochure_download'; /** * The specific KLZ product the user requested a quote for. */ @@ -957,7 +959,6 @@ export interface Auth { [k: string]: unknown; } - declare module 'payload' { export interface GeneratedTypes extends Config {} -} \ No newline at end of file +} diff --git a/src/payload/collections/FormSubmissions.ts b/src/payload/collections/FormSubmissions.ts index eee43c7f..7898be19 100644 --- a/src/payload/collections/FormSubmissions.ts +++ b/src/payload/collections/FormSubmissions.ts @@ -38,6 +38,7 @@ export const FormSubmissions: CollectionConfig = { options: [ { label: 'General Contact', value: 'contact' }, { label: 'Product Quote', value: 'product_quote' }, + { label: 'Brochure Download', value: 'brochure_download' }, ], required: true, admin: { diff --git a/tests/brochure-modal.test.ts b/tests/brochure-modal.test.ts index 946f8084..c62d786f 100644 --- a/tests/brochure-modal.test.ts +++ b/tests/brochure-modal.test.ts @@ -42,24 +42,29 @@ test('AutoBrochureModal should open after 5 seconds and submit email', async () 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 }); + const emailInput = await page.waitForSelector('#brochure-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'); + // Fill the form inside the modal + await page.type('#brochure-email', 'test-brochure@mintel.me'); - // Submit the form - await page.click('button[type="submit"]'); + // Submit the form inside the modal + 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( - () => - document.body.innerText.includes('Successfully sent') || - document.body.innerText.includes('Erfolgreich gesendet'), - { timeout: 10000 }, + () => { + const modal = document.querySelector('div[role="dialog"]'); + return ( + modal && + (modal.innerHTML.includes('Successfully sent') || + modal.innerHTML.includes('Erfolgreich gesendet')) + ); + }, + { timeout: 25000 }, ); // 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); +}, 60000);