diff --git a/blocks/columns/columns.css b/blocks/columns/columns.css index 4a63594e..b8aa5596 100644 --- a/blocks/columns/columns.css +++ b/blocks/columns/columns.css @@ -48,6 +48,12 @@ order: 1; } +.columns.block.disclaimer div { + font-size: var(--body-font-size-xxs); + line-height: var(--line-height-s); + color: var(--dark-grey); +} + .columns.columns-2-cols > div > div:first-of-type { margin-bottom: 24px; } @@ -66,7 +72,6 @@ .columns.block h3 { margin-bottom: 24px; - } @media (min-width: 600px) { diff --git a/blocks/contact-form/contact-form.css b/blocks/contact-form/contact-form.css new file mode 100644 index 00000000..11d8e01c --- /dev/null +++ b/blocks/contact-form/contact-form.css @@ -0,0 +1,215 @@ +.contact-form.block, +.contact-form.block form { + font-family: var(--font-family-primary); +} + +.contact-form.block .form-title { + font-size: var(--heading-font-size-l); + line-height: var(--line-height-xs); + font-weight: 700; + margin-top: 30px; + margin-left: 40px; + margin-bottom: 3em; +} + +.contact-form.block .company-name { + font-size: var(--heading-font-size-m); + line-height: var(--line-height-xs); + font-weight: 800; + margin-bottom: .5em; +} + +.contact-form.block .company-email, +.contact-form.block .company-phone { + padding-bottom: .5em; +} + +.contact-form.block a { + color: var(--body-color); +} + +.contact-form.block form#property-contact { + margin-top: 3rem; +} + +.contact-form.block form.contact-form .message { + display: none; + padding: 10px 4px; + margin-bottom: 1em; + flex-direction: row; + align-items: center; + border: 1px solid; + column-gap: 4px; +} + +.contact-form.block form.contact-form .message.error { + display: flex; + color: var(--error); + border-color: var(--error); +} + +.contact-form.block form.contact-form .message.success { + display: flex; + color: var(--success); + border-color: var(--success); +} + +.contact-form.block form.contact-form .message .icon { + display: none; + align-self: flex-start; + width: 20px; + height: 20px; +} + +.contact-form.block form.contact-form .message.error .icon.error { + display: block; +} + +.contact-form.block form.contact-form .message.success .icon.success { + display: block; +} + +.contact-form.block form.contact-form .message .details { + display: flex; + flex-direction: column; +} + +.contact-form.block form.contact-form .message span { + font-size: var(--body-font-size-xs); + line-height: var(--line-height-s); +} + +.contact-form.block form.contact-form .inputs .name, +.contact-form.block form.contact-form .inputs .contact-info, +.contact-form.block form.contact-form .inputs .size { + display: block; +} + +.contact-form.block form.contact-form .inputs input[type="text"], +.contact-form.block form.contact-form .inputs input[type="email"], +.contact-form.block form.contact-form .inputs textarea, +.contact-form.block form.contact-form .inputs select, +.contact-form.block form.contact-form .inputs select option { + height: 50px; + width: 100%; + padding-left: 15px; + margin-bottom: 1em; + font-size: var(--body-font-size-s); + line-height: var(--line-height-xs); + color: var(--body-color); + border: 1px solid var(--dark-grey); +} + +.contact-form.block form.contact-form .inputs textarea { + width: 100%; + height: 110px; + padding: 15px; +} + +.contact-form.block form.contact-form .agent div { + font-weight: 400; + font-size: 14px; + color: var(--body-color); + letter-spacing: .5px; + line-height: 1; + display: flex; + justify-content: flex-start; + margin-bottom: 10px; + cursor: pointer; +} + +.contact-form.block form.contact-form .agent > div:first-child { + margin-bottom: .5rem; +} + +.contact-form.block form.contact-form .agent .agent-check { + display: inline-flex; + gap: 10px; +} + +.contact-form.block form.contact-form .agent input[type="radio"] { + height: 24px; + min-width: 24px; + width: 24px; + border-radius: 50%; + border: 1px solid var(--body-color); + opacity: 0; + position: absolute; + z-index: 1; + cursor: pointer; +} + +.contact-form.block form.contact-form .agent .checkbox { + cursor: pointer; + height: 24px; + width: 24px; + border: 1px solid #aaa; + margin-right: 8px; + position: relative; +} + +.contact-form.block form.contact-form .agent .checkbox svg { + display: none; + height: 10px; + width: 12px; + top: calc(50% - 5px); + position: absolute; + left: 5px; +} + +.contact-form.block form.contact-form .agent input[type="radio"]:checked+.checkbox svg { + display: block; +} + +.contact-form.block form.contact-form .inputs input[type="text"].error, +.contact-form.block form.contact-form .inputs input[type="email"].error { + color: var(--error); + background-color: var(--error-highlight); + border: 2px solid var(--error); +} + +.contact-form.block form.contact-form .cta { + padding-bottom: 2rem; +} + +.contact-form.block form.contact-form .cta .button-container button.primary { + background-color: var(--primary-color); + color: var(--white); + text-transform: uppercase; + margin-right: 12px; +} + +.contact-form.block form.contact-form .cta .button-container button.primary:hover { + background-color: var(--white); + color: var(--body-color); + border-color: var(--grey); +} + +@media (min-width: 600px) { + .contact-form.block form.contact-form .inputs div { + display: flex !important; + gap: 10px; + } + + .contact-form.block form.contact-form .inputs input[type="text"], + .contact-form.block form.contact-form .inputs input[type="email"] { + width: 50%; + } + + .contact-form.block form.contact-form .inputs input[name="title"] { + width: calc(50% - 6px); + } + + .contact-form.block form.contact-form .inputs input[name="city"] { + flex: 0 0 41.667%; + } + + .contact-form.block form.contact-form .inputs input[name="state"] { + flex: 0 0 33.333%; + } + + .contact-form.block form.contact-form .inputs input[name="postalCode"] { + flex: 0 0 25%; + flex-shrink: 1; + } +} diff --git a/blocks/contact-form/contact-form.js b/blocks/contact-form/contact-form.js new file mode 100644 index 00000000..d38dfff2 --- /dev/null +++ b/blocks/contact-form/contact-form.js @@ -0,0 +1,389 @@ +import { loadScript } from '../../scripts/aem.js'; +import { removeSideModal, i18nLookup, getCookieValue } 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{3}[)]?[-.\s]?\d{3}[-.\s]?\d{4}$/; + +// Load reCaptcha script used on all forms. +loadScript('/blocks/contact-form/forms/callback.js'); + +/** + * Adds form and cookie values to payload. + * + * @param {FormData} form - The FormData object representing the form data. + */ +function addFranchiseData(form) { + // Common form elements + const firstName = form.elements.first_name.value; + const lastName = form.elements.last_name.value; + const email = form.elements.email.value; + const phone = form.elements.phone.value; + const comments = form.elements.comments.value; + + // All forms except team inquiry + if (form.id !== 'team-inquiry') { + const hasAgentRadio = form.elements.hasAgent; + const hasAgentValue = Array.from(hasAgentRadio).find((radio) => radio.checked)?.value === 'yes'; + const officeIdMeta = document.querySelector('meta[name="office-id"]').getAttribute('content'); + const jsonObj = {}; + jsonObj.data = {}; + jsonObj.form = form.id; + + try { + const consumerCookie = getCookieValue('consumerID'); + if (consumerCookie !== null) { + jsonObj.data.consumerID = consumerCookie; + } else { + /* eslint-disable-next-line no-console */ + console.warn('Cookie not found: consumerID'); + } + } catch (error) { + /* eslint-disable-next-line no-console */ + console.error('Error getting cookie value:', error); + } + + jsonObj.data.email = email; + jsonObj.data.name = `${firstName} ${lastName}`; + jsonObj.data.recipientId = `https://${officeIdMeta}.bhhs.hsfaffiliates.com/profile/card#me`; + jsonObj.data.recipientName = 'Commonwealth Real Estate'; + jsonObj.data.recipientType = 'organization'; + jsonObj.data.source = `https://${officeIdMeta}.bhhs.hsfaffiliates.com/profile/card#me`; + jsonObj.data.telephone = phone; + jsonObj.data.text = `Name: ${firstName} ${lastName}\n + Email: ${email}\n + Phone: ${phone}\n\n + ${comments}`; + jsonObj.data.url = `${window.location.href} | ${document.title}`; + jsonObj.data.workingWithAgent = hasAgentValue; + if (form.id === 'property-contact') { + jsonObj.data.addressLocality = 'Boston'; + jsonObj.data.addressRegion = 'Boston'; + jsonObj.data.agentType = 'Boston'; + jsonObj.data.coListing = 'Boston'; + jsonObj.data.postalCode = 'Boston'; + jsonObj.data.price = 'Boston'; + jsonObj.data.priceCurrency = 'Boston'; + jsonObj.data.streetAddress = 'Boston'; + } + if (form.id === 'make-offer' || form.id === 'see-property') { + jsonObj.listAor = 'mamlspin'; + jsonObj.mlsId = '234234'; + jsonObj.mlsKey = '234234'; + jsonObj.mlsName = 'MLSPIN - MLS Property Information Network'; + jsonObj.pid = '234234'; + } + // Data format to JSON + return JSON.stringify(jsonObj); + } + + // Remaining Team Inquiry form elements + const title = form.elements.title.value; + const zip = form.elements.postalCode.value; + const country = form.elements.country.value; + const state = form.elements.state.value; + const city = form.elements.city.value; + const address1 = form.elements.addressOne.value; + const address2 = form.elements.addressTwo.value; + const numAgents = form.elements.numOfAgents.value; + const gci = form.elements.gci.value; + + const formData = new FormData(); + formData.append('FirstName', firstName); + formData.append('LastName', lastName); + formData.append('Phone', phone); + formData.append('Email', email); + formData.append('Title', title); + formData.append('Message', comments); + formData.append('ZipCode', zip); + formData.append('Country', country); + formData.append('State', state); + formData.append('City', city); + formData.append('AddressOne', address1); + formData.append('AddressTwo', address2); + formData.append('NumOfAgents', numAgents); + formData.append('GCI', gci); + formData.append('Subject', 'Join our Team Website Inquiry'); + formData.append('SendEmail', true); + formData.append('To', 'marketing@commonmoves.com'); + // Data format to URL Params + return new URLSearchParams(formData).toString(); +} + +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'); +} + +async function validateFormInputs(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.value)) { + 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('Phone number is required.')); + phone.classList.add('error'); + } + if (!phoneRegex.test(phone.value)) { + errors.push(i18n('Please enter a 10 digit phone number.')); + phone.classList.add('error'); + } + + if (form.id === 'team-inquiry') { + const numAgentsEl = form.querySelector('input[name="numOfAgents"]'); + if (!numAgentsEl.value || numAgentsEl.value.trim().length === 0) { + errors.push(i18n('Number of agents is required.')); + numAgentsEl.classList.add('error'); + } + const cgiEl = form.querySelector('input[name="CGI"]'); + if (!cgiEl.value || cgiEl.value.trim().length === 0) { + errors.push(i18n('CGI in USD is required.')); + cgiEl.classList.add('error'); + } + } + + /* eslint-disable no-undef */ + if (!errors.length) { + if (recaptchaToken) { + const payload = `user_response=${encodeURIComponent(recaptchaToken)}`; + const options = { + method: 'POST', + body: payload, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + }; + const url = '/bin/bhhs/googleRecaptchaServlet'; + await fetch(url, options) + .then((data) => { + // Handle the response based on the success property + if (!data.ok) { + errors.push(i18n('Captcha verification is required.')); + } + }) + .catch(() => { + errors.push(i18n('Captcha verification failed.')); + }); + } else { + errors.push(i18n('Captcha verification is required.')); + } + } + + if (errors.length > 0) { + displayError(errors); + return false; + } + return true; +} + +// eslint-disable no-console +const addForm = async (block) => { + const displayValue = block.style.display; + block.style.display = 'none'; + + const formName = block.firstElementChild.innerText.trim(); + const thankYou = block.firstElementChild.nextElementSibling; + const data = await fetch(`${window.hlx.codeBasePath}/blocks/contact-form/forms/${formName}.html`); + if (!data.ok) { + /* eslint-disable-next-line no-console */ + console.error(`failed to load form: ${formName}`); + block.innerHTML = ''; + return; + } + + block.innerHTML = await data.text(); + + const form = block.querySelector('form.contact-form'); + + // if there is a thank you, highjack the submission + // otherwise submit form normally. + if (thankYou) { + const oldSubmit = validateFormInputs; + thankYou.classList.add('form-thank-you'); + form.onsubmit = function handleSubmit(e) { + e.preventDefault(); + oldSubmit(this) + .then((result) => { + if (result) { + const jsonData = addFranchiseData(this); + const headers = new Headers(); + if (this.id === 'team-inquiry') { + headers.append('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); + } else { + headers.append('Content-Type', 'application/json; charset=UTF-8'); + } + const { action, method } = this; + fetch(action, { + method, + headers, + body: jsonData, + credentials: 'include', + }).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', (event) => { + event.preventDefault(); + removeSideModal(); + }); + sideModal?.replaceChildren(thankYou); + } else { + block.replaceChildren(thankYou); + block.parentNode.nextSibling.remove(); + } + if (window.grecaptcha) { + recaptchaToken = null; + } + } + }); + } + }); + return false; + }; + } + + // 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'); + // coping all script attribute to the new one + 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); + + block.style.display = displayValue; + + const cancelBtn = block.querySelector('.contact-form.block .cta button.cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + removeSideModal(); + }); + } + + [...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'); + } + }); + // create input mask + el.addEventListener('input', (e) => { + const x = e.target.value.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,4})/); + e.target.value = !x[2] ? x[1] : `${x[1]}-${x[2]}${x[3] ? `-${x[3]}` : ''}`; + }); + }); + + [...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 (window.grecaptcha) { + recaptchaToken = null; + renderRecaptcha(); + } else { + loadScript('https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit', { async: true, defer: true }); + } +}; + +export default async function decorate(block) { + const observer = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting)) { + observer.disconnect(); + addForm(block); + } + }, { + rootMargin: '300px', + }); + await observer.observe(block); +} diff --git a/blocks/contact-form/forms/callback.js b/blocks/contact-form/forms/callback.js new file mode 100644 index 00000000..87e1b35e --- /dev/null +++ b/blocks/contact-form/forms/callback.js @@ -0,0 +1,23 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable no-undef */ +let recaptchaToken = null; + +function verifyCallback(resp) { + recaptchaToken = resp; +} + +function onloadCallback() { + grecaptcha.render('captcha-20285', { + sitekey: window.placeholders.default.recaptchaSitekey, + callback: verifyCallback, + }); +} + +function renderRecaptcha() { + if (document.getElementById('captcha-20285')) { + grecaptcha.render('captcha-20285', { + sitekey: window.placeholders.default.recaptchaSitekey, + callback: verifyCallback, + }); + } +} diff --git a/blocks/contact-form/forms/contact-property.html b/blocks/contact-form/forms/contact-property.html new file mode 100644 index 00000000..63039424 --- /dev/null +++ b/blocks/contact-form/forms/contact-property.html @@ -0,0 +1,67 @@ +
+
Contact Us
+
Berkshire Hathaway HomeServices
Commonwealth Real Estate
+
realestateinquiry@commonmoves.com
+
Direct: (508) 810-0700
+
+
+ + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
Are you currently working with an agent?
+
+
+ +
+ + + +
+ yes +
+
+ +
+ + + +
+ no +
+
+
+
+
+
+
+ + +
+
+ +
\ No newline at end of file diff --git a/blocks/contact-form/forms/contact-us.html b/blocks/contact-form/forms/contact-us.html new file mode 100644 index 00000000..40c4fd3f --- /dev/null +++ b/blocks/contact-form/forms/contact-us.html @@ -0,0 +1,62 @@ +
+
+
+ + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
Are you currently working with an agent?
+
+
+ +
+ + + +
+ yes +
+
+ +
+ + + +
+ no +
+
+
+
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/blocks/contact-form/forms/join-our-team.html b/blocks/contact-form/forms/join-our-team.html new file mode 100644 index 00000000..a490033f --- /dev/null +++ b/blocks/contact-form/forms/join-our-team.html @@ -0,0 +1,786 @@ +
+
+
+ + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + + +
+ +
+
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/blocks/fragment/fragment.js b/blocks/fragment/fragment.js index 71e40355..da92d8bb 100644 --- a/blocks/fragment/fragment.js +++ b/blocks/fragment/fragment.js @@ -1,8 +1,8 @@ /* -* Fragment Block -* Include content on a page as a fragment. -* https://www.aem.live/developer/block-collection/fragment -*/ + * Fragment Block + * Include content on a page as a fragment. + * https://www.aem.live/developer/block-collection/fragment + */ import { decorateMain, @@ -45,10 +45,8 @@ export default async function decorate(block) { const link = block.querySelector('a'); const path = link ? link.getAttribute('href') : block.textContent.trim(); const fragment = await loadFragment(path); - if (fragment) { const fragmentSection = fragment.querySelector(':scope .section'); - if (fragmentSection) { block.closest('.section').classList.add(...fragmentSection.classList); block.closest('.fragment').replaceWith(...fragment.childNodes); diff --git a/blocks/hero-slides/hero-slides.js b/blocks/hero-slides/hero-slides.js index ff46460c..317923f4 100644 --- a/blocks/hero-slides/hero-slides.js +++ b/blocks/hero-slides/hero-slides.js @@ -2,7 +2,7 @@ import { createOptimizedPicture, readBlockConfig } from '../../scripts/aem.js'; // eslint-disable-next-line no-unused-vars async function fetchListings(config) { - const resp = await fetch(`${window.hlx.codeBasePath}/drafts/rrusher/listings.json`); + const resp = await fetch(`${window.hlx.codeBasePath}/luxury-listings.json`); // eslint-disable-next-line no-return-await return (await resp.json()).data; } diff --git a/blocks/hero/hero.css b/blocks/hero/hero.css index 08c0551e..d52e7d8f 100644 --- a/blocks/hero/hero.css +++ b/blocks/hero/hero.css @@ -143,6 +143,17 @@ main .section.full-width > .hero-wrapper { color: var(--white); } +.hero.block > div .content .button-container a { + border: 1px solid; + color: var(--white); +} + +.hero.block > div .content .button-container a:hover { + background-color: var(--primary-light); + border: 1px solid var(--primary-light); + color: var(--primary-color); +} + @media screen and (min-width: 600px) { .hero.block { height: 620px; diff --git a/blocks/shared/property/cards.js b/blocks/shared/property/cards.js index 10092676..52afd8fc 100644 --- a/blocks/shared/property/cards.js +++ b/blocks/shared/property/cards.js @@ -1,3 +1,5 @@ +import { decorateFormLinks } from '../../../scripts/scripts.js'; + function createImage(listing) { if (listing.SmallMedia?.length > 0) { return `${listing.StreetName}`; @@ -60,21 +62,21 @@ export function createCard(listing) {
-
+
Featured Listing ${applicationType} ${listing.mlsStatus}
-

${listing.ListPriceUS}

-
-
+

${listing.ListPriceUS}

+
+
-
-
+
+
Closed: ${listing.ClosedDate}
${listing.StreetName} @@ -105,9 +107,9 @@ export function createCard(listing) {
-
+
-
+
Listing courtesy of: ${listing.CourtesyOf}
Listing provided by: ${listing.listAor}
@@ -125,10 +127,12 @@ export function createCard(listing) { * @param {HTMLElement} parent * @param {Object[]} properties results from CREG */ + export function render(parent, properties = []) { const cards = []; properties.forEach((listing) => { cards.push(createCard(listing)); }); parent.replaceChildren(...cards); + decorateFormLinks(parent); } diff --git a/blocks/side-modal/side-modal.css b/blocks/side-modal/side-modal.css new file mode 100644 index 00000000..9e2e282d --- /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/delayed.js b/scripts/delayed.js index e61213ba..3c1f19ec 100644 --- a/scripts/delayed.js +++ b/scripts/delayed.js @@ -1,6 +1,6 @@ // eslint-disable-next-line import/no-cycle import { sampleRUM, loadScript } from './aem.js'; -import { getEnvType } from './util.js'; +import { getCookieValue, getEnvType } from './util.js'; // Core Web Vitals RUM collection sampleRUM('cwv'); @@ -17,6 +17,24 @@ function loadAdobeLaunch() { }); } +async function loadIDServlet() { + const sessionID = getCookieValue('XSESSIONID'); + const options = { + method: 'POST', + body: `sameAs=%7B%22cregcontactid%22%3A%22${sessionID}`, + headers: { + 'Content-Type': 'application/json;charset=utf-8', + }, + }; + const url = '/bin/bhhs/graphIdServlet'; + const resp = await fetch(url, options); + if (resp.ok) { + const id = await resp.json(); + const idString = JSON.stringify(id); + document.cookie = `consumerID=${idString}`; + } +} + if (!window.location.host.includes('localhost') && !window.location.host.includes('.hlx.live') && !window.location.host.includes('.hlx.page') @@ -24,3 +42,7 @@ if (!window.location.host.includes('localhost') && !window.location.host.includes('.aem.page')) { loadAdobeLaunch(); } + +if (!getCookieValue('consumerID')) { + loadIDServlet(); +} diff --git a/scripts/scripts.js b/scripts/scripts.js index 06b553ce..f38e57d7 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -163,6 +163,38 @@ function decorateVideoLinks(main) { }); } +/** + * Finds the parent anchor tag of the given event target. + * @param {Event} event - The event object. + * @returns {HTMLAnchorElement|null} - The parent anchor tag, or null if not found. + */ +function findParentAnchorTag(event) { + let element = event.target; + while (element && element.nodeName !== 'A') { + element = element.parentNode; + } + return element; +} + +/** + * Decorates form links by attaching a click event listener to open a side modal. + * @param {HTMLElement} main - The main element containing the form links. + */ +export 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(findParentAnchorTag(event)); + } + } + 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'); @@ -261,6 +293,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 dc7724b7..6a78bdd8 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -49,6 +49,43 @@ export function showModal(content) { document.body.append(modal); } +let sideModal; +let focusElement; + +export function removeSideModal() { + if (!sideModal) return; + sideModal.parentNode.remove(); + sideModal = null; + 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+/); @@ -80,9 +117,12 @@ export async function i18nLookup(prefix) { }; } -/* - * Returns the environment type based on the hostname. -*/ +/** + * Retrieves the environment type based on the provided hostname. + * + * @param {string} [hostname=window.location.hostname] - The hostname to determine the environment. + * @returns {string} The environment type ('live', 'preview', or 'dev'). + */ export function getEnvType(hostname = window.location.hostname) { const fqdnToEnvType = { 'commonmoves.com': 'live', @@ -95,6 +135,24 @@ export function getEnvType(hostname = window.location.hostname) { return fqdnToEnvType[hostname] || 'dev'; } +/** + * Retrieves the value of a cookie by its name. + * + * @param {string} cookieName - The name of the cookie to retrieve. + * @returns {string|null} The value of the cookie, or null if not found. + */ +export function getCookieValue(cookieName) { + const cookies = document.cookie.split(';'); + const foundCookie = cookies.find((cookie) => { + const trimmedCookie = cookie.trim(); + return trimmedCookie.includes(cookieName); + }); + if (foundCookie) { + return foundCookie.split('=', 2)[1]; + } + return null; +} + /** * Format a provided value to a shorthand number. * From: https://reacthustle.com/blog/how-to-convert-number-to-kmb-format-in-javascript @@ -137,6 +195,7 @@ const Util = { showModal, i18nLookup, getEnvType, + getCookieValue, }; export default Util; diff --git a/styles/lazy-styles.css b/styles/lazy-styles.css index 84e7d6c9..4293513f 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 { + overflow: hidden; +} \ No newline at end of file diff --git a/styles/styles.css b/styles/styles.css index aa09cbc9..d7078cb0 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -316,6 +316,20 @@ main .section.two-columns > div { flex: 0 1 40%; } +main .section.form-header h1 { + font-size: var(--heading-font-size-m); + font-family: var(--font-family-proxima); + font-weight: 700; + line-height: 130%; + margin-bottom: 15px; +} + +main .section.form-header p { + font-size: var(--heading-font-size-s); + margin: 0 auto 60px; + max-width: 420px; +} + /* Center rules for a section, but don't apply it to blocks! */ /* stylelint-disable-next-line no-descending-specificity */ main div[data-align="center"] h1, @@ -389,11 +403,7 @@ main .section .default-content-wrapper picture img { animation: spinner-spin 2s linear infinite; } -.button-container a { - background-color: transparent; - border: 1px solid var(--primary-color); - color: var(--primary-color); - cursor: pointer; +.button-container a, .button-container button.cancel { display: inline-block; font-family: var(--font-family-primary); font-size: var(--heading-font-size-s); @@ -494,7 +504,7 @@ main .section.banner .default-content-wrapper { background-color: var(--primary-color); } -form button[type="submit"] { +form button[type="submit"], .button-container button { display: inline-block; padding: 13px 16px; margin: 16px 0;