From 0f170402e33c304134e2ebe017b80941850ca7c5 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Fri, 2 Feb 2024 18:03:04 -0700 Subject: [PATCH] add validation --- blocks/contact-form/contact-form.css | 18 +- blocks/contact-form/contact-form.js | 221 ++++++++++++++++++ .../contact-form/forms/contact-property.html | 78 +++++++ blocks/contact-form/forms/contact-us.html | 14 +- blocks/side-modal/side-modal.css | 33 +++ blocks/side-modal/side-modal.js | 31 +++ scripts/scripts.js | 16 ++ scripts/util.js | 38 ++- styles/lazy-styles.css | 3 + 9 files changed, 438 insertions(+), 14 deletions(-) create mode 100644 blocks/contact-form/forms/contact-property.html create mode 100644 blocks/side-modal/side-modal.css create mode 100644 blocks/side-modal/side-modal.js diff --git a/blocks/contact-form/contact-form.css b/blocks/contact-form/contact-form.css index bdd0d0e1..df32f787 100644 --- a/blocks/contact-form/contact-form.css +++ b/blocks/contact-form/contact-form.css @@ -1,3 +1,9 @@ +.contact-form.block form, +.contact-form.block .company-email, +.contact-form.block .company-phone { + font-family: var(--font-family-proxima); +} + .contact-form.block .contact-form form .message { display: none; padding: 10px 4px; @@ -41,7 +47,6 @@ } .contact-form.block .contact-form form .message span { - font-family: var(--font-family-proxima); font-size: var(--body-font-size-xs); line-height: var(--line-height-s); } @@ -58,9 +63,8 @@ width: 100%; padding-left: 15px; margin-bottom: 1em; - font-family: var(--font-family-proxima); font-size: var(--body-font-size-s); - line-height: 50px; + line-height: var(--line-height-xs); color: var(--body-color); border: 1px solid var(--dark-grey); } @@ -68,24 +72,24 @@ .contact-form.block .contact-form form .inputs textarea { width: 100%; height: 110px; + padding: 15px; } .contact-form.block .contact-form form .agent > div:first-child { margin-bottom: .5rem; } -.contact-form.block .contact-form form .agent .label-check { +.contact-form.block .contact-form form .agent .agent-check { display: inline-flex; gap: 10px; } -.contact-form.block .contact-form form .agent label { +.contact-form.block .contact-form form .agent div { font-weight: 400; font-size: 14px; color: var(--body-color); letter-spacing: .5px; line-height: 1; display: flex; - align-items: center; justify-content: flex-start; margin-bottom: 15px; cursor: pointer; @@ -144,6 +148,8 @@ .contact-form.block .contact-form form .cta .button-container a.button.primary { background-color: var(--primary-color); color: var(--white); + text-transform: uppercase; + margin-right: 12px; } .contact-form.block .contact-form form .cta .button-container a.button.primary:hover { diff --git a/blocks/contact-form/contact-form.js b/blocks/contact-form/contact-form.js index b394d83d..4857bb68 100644 --- a/blocks/contact-form/contact-form.js +++ b/blocks/contact-form/contact-form.js @@ -1,3 +1,69 @@ +import { hideSideModal, i18nLookup } from '../../scripts/util.js'; + +const LOGIN_ERROR = 'There was a problem processing your request.'; +const i18n = await i18nLookup(); +const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const phoneRegex = /^\d{10}$/; + +function displayError(errors) { + const message = document.body.querySelector('.contact-form.block').querySelector('.message'); + const details = message.querySelector('.details'); + const spans = []; + [LOGIN_ERROR, ...errors].forEach((m) => { + const span = document.createElement('span'); + span.textContent = i18n(m); + spans.push(span); + }); + details.replaceChildren(...spans); + message.classList.add('error'); +} + +function isValid(form) { + const errors = []; + const firstName = form.querySelector('input[name="first_name"]'); + if (!firstName.value || firstName.value.trim().length === 0) { + errors.push(i18n('First name is required.')); + firstName.classList.add('error'); + } + + const lastName = form.querySelector('input[name="last_name"]'); + if (!lastName.value || lastName.value.trim().length === 0) { + errors.push(i18n('Last name is required.')); + lastName.classList.add('error'); + } + + const email = form.querySelector('input[name="email"]'); + if (!email.value || email.value.trim().length === 0) { + errors.push(i18n('Email address is required.')); + email.classList.add('error'); + } + if (!emailRegex.test(email)) { + errors.push(i18n('Please enter an email address in the format: email@domain.com.')); + email.classList.add('error'); + } + + const phone = form.querySelector('input[name="phone"]'); + if (!phone.value || phone.value.trim().length === 0) { + errors.push(i18n('Email address is required.')); + phone.classList.add('error'); + } + if (!phoneRegex.test(phone)) { + errors.push(i18n('Please enter a 10 digit phone number.')); + phone.classList.add('error'); + } + + if (errors.length > 0) { + displayError(errors); + return false; + } + return true; +} + +function submitContactForm(form) { + console.log('submitted'); + return isValid(form); +} + // eslint-disable no-console const addForm = async (block) => { const displayValue = block.style.display; @@ -14,6 +80,161 @@ const addForm = async (block) => { } block.innerHTML = await data.text(); + + const submitBtn = block.querySelector('.cta a.submit'); + if (submitBtn) { + submitBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + submitContactForm(block.querySelector('form')); + }); + } + + const cancelBtn = block.querySelector('.cta a.cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + hideSideModal(); + }); + } + + [...block.querySelectorAll('input[name="first_name"], input[name="last_name"]')] + .forEach((el) => { + el.addEventListener('blur', (e) => { + const { value } = e.currentTarget; + if (!value || value.trim().length === 0) { + e.currentTarget.classList.add('error'); + } else { + e.currentTarget.classList.remove('error'); + } + }); + }); + + [...block.querySelectorAll('input[name="phone"]')] + .forEach((el) => { + el.addEventListener('blur', (e) => { + const { value } = e.currentTarget; + if (!value || value.trim().length === 0 || !phoneRegex.test(value)) { + e.currentTarget.classList.add('error'); + } else { + e.currentTarget.classList.remove('error'); + } + }); + }); + + [...block.querySelectorAll('input[name="email"]')] + .forEach((el) => { + el.addEventListener('blur', (e) => { + const { value } = e.currentTarget; + if (!value || value.trim().length === 0 || !emailRegex.test(value)) { + e.currentTarget.classList.add('error'); + } else { + e.currentTarget.classList.remove('error'); + } + }); + }); + + if (thankYou) { + const form = block.querySelector('#contactForm'); + const oldSubmit = form.onsubmit; + thankYou.classList.add('form-thank-you'); + form.onsubmit = function handleSubmit() { + if (oldSubmit.call(this)) { + const body = new FormData(this); + const { action, method } = this; + fetch(action, { method, body, redirect: 'manual' }).then((resp) => { + /* eslint-disable-next-line no-console */ + if (!resp.ok) console.error(`Form submission failed: ${resp.status} / ${resp.statusText}`); + const firstContent = thankYou.firstElementChild; + if (firstContent.tagName === 'A') { + // redirect to thank you page + window.location.href = firstContent.href; + } else { + // show thank you content + const btn = thankYou.querySelector('a'); + const sideModal = document.querySelector('.side-modal-form'); + if (btn && sideModal) { + btn.setAttribute('href', '#'); + btn.addEventListener('click', (e) => { + e.preventDefault(); + hideSideModal(); + }); + sideModal?.replaceChildren(thankYou); + } else { + block.replaceChildren(thankYou); + } + } + }); + } + return false; + }; + } + + // If the form has it's own styles, add them. + const styles = block.querySelectorAll('style'); + styles.forEach((styleSheet) => { + document.head.appendChild(styleSheet); + }); + + // If the form has it's own scripts, load them one by one to maintain execution order. + // eslint-disable-next-line no-restricted-syntax + for (const script of [...block.querySelectorAll('script')]) { + let waitForLoad = Promise.resolve(); + // The script element added by innerHTML is NOT executed. + // The workaround is to create the new script tag, copy attibutes and content. + const newScript = document.createElement('script'); + newScript.setAttribute('type', 'text/javascript'); + // Copy script attributes to the new element. + script.getAttributeNames().forEach((attrName) => { + const attrValue = script.getAttribute(attrName); + newScript.setAttribute(attrName, attrValue); + + if (attrName === 'src') { + waitForLoad = new Promise((resolve) => { + newScript.addEventListener('load', resolve); + }); + } + }); + newScript.innerHTML = script.innerHTML; + script.remove(); + document.body.append(newScript); + + // eslint-disable-next-line no-await-in-loop + await waitForLoad; + } + + const inputs = block.querySelectorAll('input'); + inputs.forEach((formEl) => { + formEl.placeholder = i18n(formEl.placeholder); + formEl.ariaLabel = i18n(formEl.ariaLabel); + }); + + const taEl = block.querySelector('textarea'); + if (taEl && taEl.placeholder) taEl.placeholder = i18n(taEl.placeholder); + + // Get all checkboxes with class 'checkbox' + const checkboxes = document.querySelectorAll('input[type="checkbox"]'); + + // Define a function declaration to handle the change event + function handleChange() { + // Store the clicked checkbox in a variable + const clickedCheckbox = this; + + // Uncheck all checkboxes that are not the clicked checkbox + checkboxes.forEach((cb) => { + if (cb !== clickedCheckbox) { + cb.checked = false; + } + }); + } + + // Add the change event listener to each checkbox using the function declaration + checkboxes.forEach((checkbox) => { + checkbox.addEventListener('change', handleChange); + checkbox.nextElementSibling.addEventListener('change', handleChange); + }); + block.style.display = displayValue; }; diff --git a/blocks/contact-form/forms/contact-property.html b/blocks/contact-form/forms/contact-property.html new file mode 100644 index 00000000..aed6c0e5 --- /dev/null +++ b/blocks/contact-form/forms/contact-property.html @@ -0,0 +1,78 @@ +
+

Contact Us

+
+
+
Direct:
+
+
+ + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
Are you currently working with an agent?
+
+
+ +
+ + + +
+ yes +
+
+ +
+ + + +
+ no +
+
+
+
+
+
+ + +
+ +
+
+
+ Send + Cancel +
+
+
+
\ No newline at end of file diff --git a/blocks/contact-form/forms/contact-us.html b/blocks/contact-form/forms/contact-us.html index 5418d5ff..941dbf99 100644 --- a/blocks/contact-form/forms/contact-us.html +++ b/blocks/contact-form/forms/contact-us.html @@ -1,5 +1,5 @@
-
+
@@ -30,8 +30,8 @@
Are you currently working with an agent?
-
-
@@ -65,7 +65,7 @@
diff --git a/blocks/side-modal/side-modal.css b/blocks/side-modal/side-modal.css new file mode 100644 index 00000000..557d3d8e --- /dev/null +++ b/blocks/side-modal/side-modal.css @@ -0,0 +1,33 @@ +aside.side-modal { + overflow: hidden scroll; + position: fixed; + top: 200px; + bottom: 0; + width: 100vw; + right: -100vw; + transition: right .2s cubic-bezier(.4,0,.2,1) .1s; + background-color: white; + z-index: 1090; +} + +aside.side-modal[aria-expanded=true] { + right: 0; +} + +aside.side-modal > div { + width: 100%; + padding: 53px 15px 15px; +} + +@media (min-width: 768px) { + aside.side-modal { + width: 50vw; + right: -50vw; + } +} + +@media (min-width: 992px) { + aside.side-modal > div { + padding: 53px 16% 16%; + } +} diff --git a/blocks/side-modal/side-modal.js b/blocks/side-modal/side-modal.js new file mode 100644 index 00000000..850807a5 --- /dev/null +++ b/blocks/side-modal/side-modal.js @@ -0,0 +1,31 @@ +/* eslint-disable import/prefer-default-export */ +import { + decorateSections, decorateBlocks, loadBlocks, decorateButtons, decorateIcons, loadCSS, +} from '../../scripts/aem.js'; + +export async function showSideModal(a) { + const { href } = a; + const module$ = import(`${window.hlx.codeBasePath}/scripts/util.js`); + await loadCSS(`${window.hlx.codeBasePath}/blocks/side-modal/side-modal.css`); + const content = await fetch(`${href}.plain.html`); + + async function decorateSideModal(container) { + decorateButtons(container); + decorateIcons(container); + decorateSections(container); + decorateBlocks(container); + + container.classList.add('side-modal-form'); + const [theForm] = container.children; + theForm.classList.add('form'); + + await loadBlocks(container); + } + + if (content.ok) { + const html = await content.text(); + const fragment = document.createRange().createContextualFragment(html); + const [module] = await Promise.all([module$]); + module.showSideModal(fragment.children, decorateSideModal); + } +} diff --git a/scripts/scripts.js b/scripts/scripts.js index 3f1027ca..d45af915 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -152,6 +152,21 @@ function decorateVideoLinks(main) { }); } +function decorateFormLinks(main) { + async function openSideModal(event) { + event.preventDefault(); + const module = await import(`${window.hlx.codeBasePath}/blocks/side-modal/side-modal.js`); + if (module.showSideModal) { + await module.showSideModal(event.target); + } + } + main.querySelectorAll('a[href*="form"]').forEach((a) => { + if (a.href.endsWith('-form')) { + a.addEventListener('click', openSideModal); + } + }); +} + function decorateImages(main) { main.querySelectorAll('.section .default-content-wrapper picture').forEach((picture) => { const img = picture.querySelector('img'); @@ -262,6 +277,7 @@ export function decorateMain(main) { decorateSections(main); decorateBlocks(main); decorateVideoLinks(main); + decorateFormLinks(main); decorateImages(main); } diff --git a/scripts/util.js b/scripts/util.js index f89c8c4b..a0196e1c 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -1,4 +1,4 @@ -import { fetchPlaceholders } from './aem.js'; +import { fetchPlaceholders, loadCSS } from './aem.js'; /** * Creates the standard Spinner Div. @@ -49,6 +49,42 @@ export function showModal(content) { document.body.append(modal); } +let sideModal; +let focusElement; + +export function hideSideModal() { + if (!sideModal) return; + sideModal.ariaExpanded = false; + document.body.classList.remove('disable-scroll'); + if (focusElement) focusElement.focus(); +} + +export async function showSideModal(content, decorateContent) { + if (!sideModal) { + const fragment = document.createRange().createContextualFragment(` +
+ +
+ `); + sideModal = fragment.querySelector('.side-modal'); + document.body.append(...fragment.children); + } + const container = sideModal.querySelector('div'); + container.replaceChildren(...content); + + if (decorateContent) await decorateContent(container); + + // required delay for animation to work + setTimeout(() => { + document.body.classList.add('disable-scroll'); + sideModal.ariaExpanded = true; + }); + + focusElement = document.activeElement; +} + function createTextKey(text) { // create a key that can be used to look up the text in the placeholders const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/); diff --git a/styles/lazy-styles.css b/styles/lazy-styles.css index 84e7d6c9..c8ced821 100644 --- a/styles/lazy-styles.css +++ b/styles/lazy-styles.css @@ -1 +1,4 @@ /* add global styles that can be loaded post LCP here */ +body.disable-scroll, body.disable-scroll main { + overflow: hidden; +} \ No newline at end of file