diff --git a/README.md b/README.md index 90c552b7..453a7b48 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ In this setup, the proxy is configured to route non-API traffic on the Staging d This setup uses a locally resolved domain, which will Proxyman will use to route the traffic. All non-API traffic will route to localhost, all API traffic will go to the Stage domain. -1. Add an entry to `/etc/maps`: +1. Add an entry to `/etc/hosts`: Proxyman won't proxy localhost, so a custom domain is required. Add the following (if you already have a host entry for 127.0.0.1, simply add the new domain) > 127.0.0.1 proxyman.debug diff --git a/blocks/agent-about/agent-about.css b/blocks/agent-about/agent-about.css index 49b456d0..29fbbb77 100644 --- a/blocks/agent-about/agent-about.css +++ b/blocks/agent-about/agent-about.css @@ -7,10 +7,6 @@ letter-spacing: normal; } -.agent-about.block a { - cursor: pointer; -} - .agent-about.block .hide { display: none; } @@ -20,6 +16,8 @@ display: inline-block; margin-top: 1rem; text-decoration: underline; + font-size: var(--body-font-size-xs); + color: var(--black); } .agent-about.block a.view-more::after { @@ -33,7 +31,7 @@ .agent-about.block>div.cols-1, .agent-about.block>div.cols-2, .agent-about.block>div.cols-3 { - padding-bottom: 1rem; + padding-bottom: 2rem; } .agent-about.block>div>div:first-of-type { diff --git a/blocks/agent-about/agent-about.js b/blocks/agent-about/agent-about.js index 80949538..fabeecae 100644 --- a/blocks/agent-about/agent-about.js +++ b/blocks/agent-about/agent-about.js @@ -1,3 +1,4 @@ +import { getMetadata } from '../../scripts/aem.js'; import { a, div, ul, li, } from '../../scripts/dom-helpers.js'; @@ -18,7 +19,23 @@ const viewMoreOnClick = (name, anchor, block) => { }); }; +const getCol = (list, colText) => { + const colsUl = ul(); + list.split(',').forEach((x) => { + colsUl.append(li(x.trim())); + }); + return div(div(colText), div(colsUl)); +}; + export default function decorate(block) { + const aboutText = getMetadata('about'); + const accreditations = getMetadata('professional-accreditations'); + const languages = getMetadata('languages'); + + block.replaceChildren(div(div('About'), div(aboutText)), + getCol(accreditations, 'Professional Accreditations'), + getCol(languages, 'Languages')); + const children = [...block.children]; if (children?.length) { children.forEach((child, index) => { @@ -31,7 +48,7 @@ export default function decorate(block) { child.children[1].classList.add('hide'); child.append(div({ class: `${name}-truncate` }, `${child.children[1].textContent.substring(0, threshold)}...`)); - const anchor = a({ class: 'view-more' }); + const anchor = a({ class: 'view-more', href: '#' }); child.append(anchor); viewMoreOnClick(name, anchor, block); } @@ -43,15 +60,15 @@ export default function decorate(block) { if (liItems.length > threshold) { child.children[1].classList.add('hide'); - const tempUl = ul({ }); + const tempUl = ul(); Array.from(child.children[1].querySelectorAll('li')) .slice(0, threshold).forEach((liItem) => { - const tempLi = li({}, liItem.textContent); + const tempLi = li(liItem.textContent); tempUl.append(tempLi); }); child.append(div({ class: `${name}-truncate` }, tempUl)); - const anchor = a({ class: 'view-more' }); + const anchor = a({ class: 'view-more', href: '#' }); child.append(anchor); viewMoreOnClick(name, anchor, block); } diff --git a/blocks/agent-address/agent-address.css b/blocks/agent-address/agent-address.css new file mode 100644 index 00000000..bc074896 --- /dev/null +++ b/blocks/agent-address/agent-address.css @@ -0,0 +1,35 @@ +.agent-address.block { + padding: 2rem; + background-color: var(--tertiary-color); +} + +.agent-address.block .address { + margin-bottom: 2rem; +} + +.agent-address.block .address>p { + margin-bottom: 0; + font-size: var(--body-font-size-xs); +} + +.agent-address.block a { + border: 1px solid var(--primary-color); + color: var(--primary-color); + font-weight: var(--font-weight-bold); + letter-spacing: var(--letter-spacing-m); + text-transform: uppercase; + padding: 0.5rem 1rem; + text-decoration: none; + font-size: var(--body-font-size-s); +} + +.agent-address.block a:hover { + color: var(--primary-light); + background-color: var(--primary-color); +} + +@media (min-width: 600px) { + .agent-address.block { + display: none; + } +} diff --git a/blocks/agent-address/agent-address.js b/blocks/agent-address/agent-address.js new file mode 100644 index 00000000..187419f0 --- /dev/null +++ b/blocks/agent-address/agent-address.js @@ -0,0 +1,22 @@ +import { getMetadata } from '../../scripts/aem.js'; +import { + a, div, p, +} from '../../scripts/dom-helpers.js'; + +export default function decorate(block) { + const streetAddress = getMetadata('streetaddress'); + const addressLocality = getMetadata('addresslocality'); + const addressRegion = getMetadata('addressregion'); + const postalCode = getMetadata('postalcode'); + + const textDiv = div({ class: 'address' }, + p('Berkshire Hathaway HomeServices'), + p('Commonwealth Real Estate'), + p(streetAddress), + p(`${addressLocality}, ${addressRegion} ${postalCode}`), + ); + const text = `${streetAddress}, ${addressLocality}, ${addressRegion} ${postalCode}`; + + const anchor = a({ href: `https://maps.google.com/maps?q=${text}`, target: '_blank' }, 'Directions'); + block.replaceChildren(textDiv, anchor); +} diff --git a/blocks/agent-profile/agent-profile.js b/blocks/agent-profile/agent-profile.js index 61f963b7..191b685f 100644 --- a/blocks/agent-profile/agent-profile.js +++ b/blocks/agent-profile/agent-profile.js @@ -8,13 +8,13 @@ const getPhoneDiv = () => { let phoneUl; if (getMetadata('direct-phone')) { - phoneUl = ul({}); - phoneUl.append(li({}, 'Direct: ', getMetadata('direct-phone'))); + phoneUl = ul(); + phoneUl.append(li('Direct: ', getMetadata('direct-phone'))); } if (getMetadata('office-phone')) { - phoneUl = phoneUl || ul({}); - phoneUl.append(li({}, 'Office: ', getMetadata('office-phone'))); + phoneUl = phoneUl || ul(); + phoneUl.append(li('Office: ', getMetadata('office-phone'))); } if (phoneUl) { @@ -62,9 +62,9 @@ const getSocialDiv = () => { ['facebook', 'instagram', 'linkedin'].forEach((x) => { const url = getMetadata(x); - socialUl = socialUl || ul({}); + socialUl = socialUl || ul(); if (url) { - const socialLi = li({}, a({ + const socialLi = li(a({ href: url, class: x, title: x, 'aria-label': x, }, span({ class: `icon icon-${x}` }))); socialUl.append(socialLi); @@ -82,7 +82,7 @@ const getSocialDiv = () => { export default async function decorate(block) { const profileImage = getImageDiv(); const profileContent = div({ class: 'profile-content' }, - div({ class: 'name' }, h1({}, getMetadata('name'))), + div({ class: 'name' }, h1(getMetadata('name'))), div({ class: 'designation' }, getMetadata('designation')), ); diff --git a/blocks/agent-transactions/agent-transactions.css b/blocks/agent-transactions/agent-transactions.css new file mode 100644 index 00000000..061fc67c --- /dev/null +++ b/blocks/agent-transactions/agent-transactions.css @@ -0,0 +1,157 @@ +.agent-transactions.block table { + width: 100%; + font-size: var(--body-font-size-s); + line-height: var(--line-height-m); + margin-bottom: 0.25rem; +} + +.agent-transactions.block h1 { + font-size: var(--heading-font-size-l); + line-height: var(--line-height-s); + color: var(--primary-color); + margin: 0 1.875rem 1.5rem 0; + font-weight: 600; +} + +.agent-transactions.block .hide { + display: none; +} + +.agent-transactions.block .show { + display: table-row; +} + +.agent-transactions.block thead { + background: var(--platinum); + font-size: var(--body-font-size-xs); + line-height: var(--line-height-m); + font-weight: var(--font-weight-bold); + text-transform: capitalize; +} + +.agent-transactions.block tbody td { + padding-bottom: 0.5rem; + padding-top: 0.25rem; + font-size: var(--body-font-size-xs); +} + +.agent-transactions.block a { + line-height: var(--line-height-m); + letter-spacing: var(--letter-spacing-m); + text-transform: uppercase; + padding-left: 1rem; + cursor: pointer; +} + +.agent-transactions.block a.show-more::after { + content: "View More"; + display: inline-block; + background-image: url("../../icons/arrow-down.svg"); + background-repeat: no-repeat; + background-position: right; + padding-right: 1rem; + font-size: var(--body-font-size-xs); + font-weight: var(--font-weight-bold); +} + +.agent-transactions.block a.show-less::after { + content: "View Less"; + display: inline-block; + background-image: url("../../icons/arrow-up.svg"); + background-repeat: no-repeat; + background-position: right; + padding-right: 1rem; + font-size: var(--body-font-size-xs); + font-weight: var(--font-weight-bold); +} + +.agent-transactions.block thead th { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.agent-transactions.block thead th:first-of-type { + padding-left: 1rem; +} + +.agent-transactions.block tbody td:first-of-type { + padding-left: 1rem; +} + +.agent-transactions.block .default { + display: table-cell; +} + +.agent-transactions.block .medium, +.agent-transactions.block .large, +.agent-transactions.block .xl { + display: none; +} + +.agent-transactions.block .sold-price { + width: 40%; +} + +@media (min-width: 600px) { + .agent-transactions.block .medium { + display: table-cell; + } + + .agent-transactions.block .address { + width: 48.7%; + } + + .agent-transactions.block .city { + width: 24.5%; + } + + .agent-transactions.block .state { + width: 7.4%; + } + + .agent-transactions.block .sold-price { + width: 40%; + } +} + +@media (min-width: 992px) { + .agent-transactions.block h1 { + font-size: 1.875rem; + } + + .agent-transactions.block thead { + font-size: var(--body-font-size-s); + } + + .agent-transactions.block tbody td { + font-size: var(--body-font-size-s); + padding-top: 0.5rem; + } + + .agent-transactions.block .large { + display: table-cell; + } + + .agent-transactions.block .address { + width: auto; + } + + .agent-transactions.block .city { + width: 15%; + } + + .agent-transactions.block .sold-price { + width: 13%; + } +} + +@media (min-width: 1200px) { + .agent-transactions.block .xl { + display: table-cell; + } + + .agent-transactions.block .beds, + .agent-transactions.block .baths { + width: 5%; + } +} diff --git a/blocks/agent-transactions/agent-transactions.js b/blocks/agent-transactions/agent-transactions.js new file mode 100644 index 00000000..d7e13c00 --- /dev/null +++ b/blocks/agent-transactions/agent-transactions.js @@ -0,0 +1,105 @@ +import { + table, tbody, th, thead, tr, td, h1, a, +} from '../../scripts/dom-helpers.js'; +import { getMetadata } from '../../scripts/aem.js'; + +const getClosedTransactions = async () => { + const agentId = getMetadata('agent-id'); + const formattedData = []; + + try { + const response = await fetch(`/bin/bhhs/agentPropertyListingsServlet.${agentId}.json`); + const data = await response.json(); + + if (data && data?.closedTransactions?.properties?.length) { + data.closedTransactions.properties.forEach((property) => { + formattedData.push({ + address: property.StreetName, + city: property.City, + state: property.StateOrProvince, + 'sold-price': property.closePrice, + beds: property.BedroomsTotal, + baths: property.BathroomsTotal, + 'approx-sq-ft': property.LivingArea, + type: property.PropertyType, + 'closed-date': property.ClosedDate, + }); + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching closed transactions', error); + } + + return formattedData; +}; + +export default async function decorate(block) { + const transactionsData = await getClosedTransactions(); + if (transactionsData.length === 0) { + return; + } + + const thList = ['address', 'city', 'state', 'sold price', 'beds', 'baths', 'approx sq. ft.', 'type', 'closed date']; + const thDefault = { class: 'default', list: [0, 3] }; + const thMedium = { class: 'medium', list: [1, 2] }; + const thLarge = { class: 'large', list: [4, 5, 8] }; + const thXL = { class: 'xl', list: [6, 7] }; + + const theadTr = tr(); + const getClass = (index) => { + if (thDefault.list.includes(index)) { + return `${thDefault.class}`; + } + if (thMedium.list.includes(index)) { + return `${thMedium.class}`; + } + if (thLarge.list.includes(index)) { + return `${thLarge.class}`; + } + return `${thXL.class}`; + }; + + thList.forEach((x, index) => { + theadTr.appendChild(th({ class: `${x.split(' ').join('-').replace(/\./g, '')} ${getClass(index)}` }, x)); + }); + + const trBody = tbody(); + const intialTransactionsCount = 6; // show 6 transactions initially + + transactionsData.forEach((data, topIndex) => { + const trElement = tr({ class: `${topIndex < intialTransactionsCount ? 'show' : 'hide'}` }); + + thList.forEach((x, index) => { + const key = x.split(' ').join('-').replace(/\./g, ''); + trElement.appendChild(td({ class: `${x.split(' ').join('-').replace(/\./g, '')} ${getClass(index)}` }, (data[key]) || '')); + }); + + trBody.appendChild(trElement); + }); + + const tableElement = table(thead(theadTr), trBody); + const heading1 = h1('Closed Transactions'); + const anchor = a({ class: 'show-more' }); + anchor.addEventListener('click', () => { + if (anchor.classList.contains('show-more')) { + anchor.classList.remove('show-more'); + anchor.classList.add('show-less'); + const tBodyTr = block.querySelectorAll('tbody tr.hide'); + tBodyTr.forEach((trElement) => { + trElement.classList.remove('hide'); + }); + } else { + anchor.classList.remove('show-less'); + anchor.classList.add('show-more'); + const tBodyTr = block.querySelectorAll('tbody tr'); + tBodyTr.forEach((trElement, index) => { + if (index >= intialTransactionsCount) { + trElement.classList.add('hide'); + } + }); + } + }); + + block.replaceChildren(heading1, tableElement, anchor); +} 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..03a1cd47 --- /dev/null +++ b/blocks/contact-form/contact-form.css @@ -0,0 +1,234 @@ +.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 .contact-details { + display: flex; + flex-direction: row; + gap: 1em; + margin-bottom: 2em; +} + +.contact-form.block .profile { + display: flex; + flex-direction: column; + gap: 1em; +} + +.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; +} + + +.contact-form.block form.contact-form .agent div.disclaimer { + font-size: var(--body-font-size-xxs); + line-height: var(--line-height-s); + color: var(--dark-grey); +} + +.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..2c33df20 --- /dev/null +++ b/blocks/contact-form/contact-form.js @@ -0,0 +1,433 @@ +import { loadScript } from '../../scripts/aem.js'; +// import { getEnvelope } from '../../scripts/apis/creg/creg.js'; +import { removeSideModal, i18nLookup, getCookieValue } from '../../scripts/util.js'; +import { a, div, img } from '../../scripts/dom-helpers.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'); + +function getImageURL(jsonString) { + try { + const data = JSON.parse(jsonString); + if (Array.isArray(data) && data.length > 0) { + const imageUrl = new URL(data[0].url); + // Replace the hostname and pathname with the new ones + imageUrl.hostname = 'hsfazpw2storagesf1.blob.core.windows.net'; + imageUrl.pathname = `/hsflibrary${imageUrl.pathname}`; + return imageUrl.toString(); + } + } catch (error) { + return '/media/images/no-profile-image.png'; + } + return null; // Add a return statement at the end of the function +} + +/** + * 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?.placeholder) taEl.placeholder = i18n(taEl.placeholder); + if (window.selectedListing) { + // const prop = await findListing(); + const prop = window.selectedListing; + // if the listing agent is supposed to be displayed vs the office + if (prop.propertyDetails.listAgentCd) { + const info = block.querySelector('.contact-info'); + const pic = getImageURL(prop.listAgent.reAgentDetail.image); + const profile = div({ class: 'profile' }, img({ src: pic, alt: prop.listAgent.recipientName, width: '82px' })); + info.insertAdjacentElement('beforebegin', profile); + const name = block.querySelector('.company-name'); + const link = a({ href: '#' }, prop.listAgent.recipientName); // TODO: add link to agent profile + name.replaceChildren(link); + const email = block.querySelector('.company-email a'); + email.textContent = prop.listAgent.reAgentDetail.email; + email.href = `mailto:${prop.listAgent.reAgentDetail.email}`; + const phone = block.querySelector('.company-phone a'); + phone.textContent = prop.listAgent.reAgentDetail.officeTelephone; + phone.href = `tel:${prop.listAgent.reAgentDetail.officeTelephone}`; + } + + taEl.value = `Hi, I would like more information about ${prop.propertyDetails.unparsedAddress}`; + + if (window.location.pathname.length === 1) { + block.querySelector('.disclaimer').remove(); + } + } + + 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..452c2be9 --- /dev/null +++ b/blocks/contact-form/forms/contact-property.html @@ -0,0 +1,71 @@ +
+
Contact Us
+
+
+
Berkshire Hathaway HomeServices
Commonwealth Real Estate
+ + +
+
+
+
+ + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
Are you currently working with an agent?
+
+
+ +
+ + + +
+ yes +
+
+ +
+ + + +
+ no +
+
+
By providing this information, you are giving permission to Berkshire Hathaway HomeServices, Constellation1, a division of Constellation Web Solutions, Inc., and the members of the Berkshire Hathaway HomeServices real estate network (1) to contact you in response to your specific question or message, and (2) to register you in our system in order to communicate with you about properties for sale or rent in locations of interest to you. For more about how we will use your contact information, review our Privacy Policy.
+
+
+
+
+ + +
+
+ +
\ 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/floatingagent/floatingagent.css b/blocks/floatingagent/floatingagent.css new file mode 100644 index 00000000..688323d9 --- /dev/null +++ b/blocks/floatingagent/floatingagent.css @@ -0,0 +1,88 @@ +.floatingagent.block { + display: flex; + align-items: center; + justify-content: flex-start; + position: fixed; + bottom: 0; + left: 0; + width: 100%; + height: 100px; + background-color: var(--white); + padding: 10px; + border-top: 1px solid #ccc; + z-index: 999; +} + +.floatingagent.block > .floating-agent-col { + display: none; +} + +.floatingagent.block > .agentinfo { + display: none; +} + +.floatingagent.block > .contactagent { + display: block; + align-self: center; + background: var(--primary-color); + color: var(--tertiary-color); + border: 0; + padding: 10px; + margin-left: 35%; +} + +@media (min-width: 600px) { + .floatingagent.block { + justify-content: space-between; + } + + .floatingagent.block > .floating-agent-col { + margin-left: 100px; + display: flex; + margin-right: 10px; + flex: none; + margin-bottom: 20px; + } + + .floatingagent.block > .agentinfo { + display: block; + flex-grow: 1; + margin-top: 10px; + margin-bottom: 15px; + } + + .floatingagent.block > .agentinfo > p { + margin-block-end: 0; + font-family: var(--font-family-primary); + font-size: var(--body-font-size-xxs); + font-style: normal; + font-weight: var(--font-weight-normal); + letter-spacing: normal; + color: #2a2223; + } + + .floatingagent.block > .agentinfo > h2 { + font-size: var(--heading-font-size-m); + line-height: 130%; + letter-spacing: var(--letter-spacing-xxxs); + vertical-align: top; + color: #2a2223; + width: max-content; + } + + .floatingagent.block > .floating-agent-col > picture { + height: 40px; + width: 30px; + margin-right: 40px; + } + + .floatingagent.block > .floating-agent-col > picture > img { + display: inline; + } + + .floatingagent.block > .contactagent { + flex: none; + margin-right: 5%; + margin-left: 10px; + } +} diff --git a/blocks/floatingagent/floatingagent.js b/blocks/floatingagent/floatingagent.js new file mode 100644 index 00000000..ef0d685c --- /dev/null +++ b/blocks/floatingagent/floatingagent.js @@ -0,0 +1,59 @@ +import { + getMetadata, +} from '../../scripts/aem.js'; +import { + button, + div, + h2, + img, + p, + strong, +} from '../../scripts/dom-helpers.js'; + +export default function decorate(block) { + const agentName = getMetadata('name'); + const agentDesc = getMetadata('desc'); + const pic = getMetadata('pic'); + const lic = getMetadata('lic'); + + const agentPicture = document.createElement('picture'); + agentPicture.appendChild(img({ + loading: 'lazy', + alt: 'Agent Image', + src: pic, + width: '48', + height: '64', + style: 'width: 48px; height: 64px;', + })); + + const agentInfo = div({ class: 'agentinfo' }, + h2(strong(agentName)), + p(agentDesc), + p(lic), + ); + + const contactButton = button({ class: 'contactagent' }, 'CONTACT AGENT'); + + block.append( + div({ class: 'floating-agent-col' }, agentPicture), + agentInfo, + contactButton, + ); + const displayedElement = document.querySelector('.floatingagent'); + + const heroElement = document.querySelector('.hero-wrapper'); + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + displayedElement.style.display = 'none'; + } else { + displayedElement.style.display = 'flex'; + } + }); + }, { + threshold: [0], + }); + + observer.observe(heroElement); +} 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/property-listing/property-listing.js b/blocks/property-listing/property-listing.js index 77881ef4..524623ae 100644 --- a/blocks/property-listing/property-listing.js +++ b/blocks/property-listing/property-listing.js @@ -3,27 +3,34 @@ import { render as renderCards } from '../shared/property/cards.js'; import Search from '../../scripts/apis/creg/search/Search.js'; import { propertySearch, getSavedProperties } from '../../scripts/apis/creg/creg.js'; import { getUserDetails } from '../../scripts/apis/user.js'; +import { + a, div, p, span, +} from '../../scripts/dom-helpers.js'; export default async function decorate(block) { // Find and process list type configurations. const config = readBlockConfig(block); + const search = await Search.fromBlockConfig(config); + search.franchiseeCode = getMetadata('office-id'); + const searchUrl = `search?${search.asCregURLSearchParameters()}`; if (config.title) { - block.innerHTML = ` -
-
- ${config.title} -
-
- `; - if (config['link-text']) { - const div = document.createElement('div'); - const url = config['link-url'] || ''; - div.innerHTML = ` -

- ${config['link-text'] || 'See More'} -

`; - block.querySelector('.header').append(div); + const blockTitle = div({ class: 'header' }, + div( + span(config.title), + ), + ); + block.replaceChildren(blockTitle); + + if (config.link) { + const moreBtn = div( + p({ class: 'button-container' }, + a({ href: config['link-url'] || searchUrl, 'aria-label': config.link || 'See More', class: 'button secondary' }, + config.link || 'See More', + ), + ), + ); + block.querySelector('.header').append(moreBtn); } } else { block.innerHTML = ''; @@ -34,10 +41,7 @@ export default async function decorate(block) { } const { contactKey } = user; - const search = await Search.fromBlockConfig(config); - search.franchiseeCode = getMetadata('office-id'); - const list = document.createElement('div'); - list.classList.add('property-list-cards', `rows-${Math.floor(search.pageSize / 8)}`); + const list = div({ class: `property-list-cards rows-${Math.floor(search.pageSize / 8)}` }); block.append(list); propertySearch(search).then((results) => { renderCards(list, results.properties); diff --git a/blocks/shared/property/cards.js b/blocks/shared/property/cards.js index f9a147bd..1b4076c9 100644 --- a/blocks/shared/property/cards.js +++ b/blocks/shared/property/cards.js @@ -1,8 +1,16 @@ +import { decorateFormLinks } from '../../../scripts/scripts.js'; +import { + a, div, domEl, img, p, span, +} from '../../../scripts/dom-helpers.js'; + function createImage(listing) { if (listing.SmallMedia?.length > 0) { - return `${listing.StreetName}`; + const tempImg = img({ + src: listing.SmallMedia[0].mediaUrl, alt: listing.StreetName, loading: 'lazy', class: 'property-thumbnail', + }); + return tempImg; } - return '
no images available
'; + return div({ class: 'property-no-available-image' }, span('no images available')); } export function createCard(listing) { @@ -25,102 +33,106 @@ export function createCard(listing) { specs.push(`${listing.LivingArea} ${listing.LivingAreaUnits}`); } - const item = document.createElement('div'); - item.classList.add('listing-tile'); - item.dataset.id = listing.PropId; + let classes = 'listing-tile'; if (listing.OpenHouses?.length > 0) { - item.classList.add('has-open-houses'); + classes += ' has-open-houses'; } if (listing.FeaturedListing) { - item.classList.add('is-featured'); + classes += ' is-featured'; } if (listing.PdpPath.includes('LuxuryTheme=true')) { - item.classList.add('is-luxury'); + classes += ' is-luxury'; } const applicationType = listing.ListingType && listing.ListingType === 'For Rent' ? `${listing.ListingType}` : ''; if (listing.ClosedDate !== '01/01/0001') { - item.classList.add('is-sold'); + classes += 'is-sold'; listing.mlsStatus = 'Closed'; } - item.innerHTML = ` - -
-
- ${createImage(listing)} -
-
-
-
Luxury Collection
-
- - Open House -
-
-
-
-
- Featured Listing - ${applicationType} - ${listing.mlsStatus} -
-
-

${listing.ListPriceUS}

-
-
-
-
-
-
-
-
Closed: ${listing.ClosedDate}
-
- ${listing.StreetName} -
- ${listing.City}, ${listing.StateOrProvince} ${listing.PostalCode} -
-
${specs.join(' / ')}
-
-
- -
-
-
-
-
Listing courtesy of: ${listing.CourtesyOf}
-
Listing provided by: ${listing.listAor}
-
-
- Disclaimer Logo Image -
-
- `; - return item; + const newEl = div({ class: classes }, + a({ href: detailsPath, rel: 'noopener', 'aria-labelledby': `listing-${listing.ListingId}-address` }, + div({ class: 'listing-image-container' }, + div({ class: 'property-image' }, createImage(listing)), + div({ class: 'image-position-top' }, + div({ class: 'property-labels' }, + div({ class: 'property-label luxury' }, 'Luxury Collection'), + div({ class: 'property-label open-house' }, + span({ class: 'icon icon-openhouse' }, 'Open House'), + ), + ), + ), + div({ class: 'image-position-bottom' }, + div({ class: 'property-labels' }, + span({ class: 'property-label featured' }, 'Featured Listing'), + applicationType, + span({ class: 'property-label' }, listing.mlsStatus), + ), + div({ class: 'property-price' }, + p(listing.ListPriceUS), + ), + ), + ), + ), + div({ class: 'property-details' }, + div({ class: 'property-info-wrapper' }, + div({ class: 'property-info' }, + div({ class: 'sold-date' }, `Closed: ${listing.ClosedDate}`), + div({ id: `listing-${listing.PropId}-address`, class: 'address' }, + listing.StreetName, + domEl('br'), + `${listing.City}, `, + `${listing.StateOrProvince} `, + listing.PostalCode, + ), + div({ class: 'specs' }, specs.join(' / ')), + ), + ), + div({ class: 'property-buttons' }, + div({ class: 'buttons-row-flex' }, + a({ 'aria-label': `Contact us about ${listing.StreetName}`, href: '/fragments/contact-property-form', class: 'button-property' }, + span({ class: 'icon icon-envelope' }, + img({ + 'data-icon-name': 'envelope', src: '/icons/envelope.svg', loading: 'lazy', alt: 'envelope', + }), + ), + span({ class: 'icon icon-envelopedark' }, + img({ + 'data-icon-name': 'envelopedark', src: '/icons/envelopedark.svg', loading: 'lazy', alt: 'envelope', + }), + ), + ), + a({ 'aria-label': `Save ${listing.StreetName} to saved properties.`, href: '#', class: 'button-property' }, + span({ class: 'icon icon-heartempty' }, + img({ + 'data-icon-name': 'heartempty', src: '/icons/heartempty.svg', loading: 'lazy', alt: 'heart', + }), + ), + span({ class: 'icon icon-heartemptydark' }, + img({ + 'data-icon-name': 'heartempty', src: '/icons/heartemptydark.svg', loading: 'lazy', alt: 'heart', + }), + ), + ), + ), + ), + ), + domEl('hr'), + div({ class: 'extra-info' }, + div( + div({ class: 'courtesy-info' }, `Listing courtesy of: ${listing.CourtesyOf}`), + div({ class: 'courtesy-provided' }, `Listing provided by: ${listing.listAor}`), + ), + div({ class: `listing-aor ${listing.listAor.toLowerCase()}` }, + img({ + class: 'rimls-image', src: '/styles/images/rimls_logo.jpg', alt: 'Disclaimer Logo Image', loading: 'lazy', height: '20', width: '33', + }), + ), + ), + ); + return newEl; } /** @@ -129,10 +141,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..9fb8c48b --- /dev/null +++ b/blocks/side-modal/side-modal.css @@ -0,0 +1,43 @@ +aside.side-modal { + overflow: hidden scroll; + position: fixed; + top: 65px; + bottom: 0; + width: 100%; + right: 0; + 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: 600px) { + aside.side-modal { + top: 0; + width: 600px; + right: -600px; + } + + aside.side-modal > div { + padding: 53px 45px 15px; + } + + body > div > div.side-modal-overlay { + background-color: #212529; + width: 100%; + height: 100%; + position: fixed; + opacity: .5; + z-index: 1040; + top: 0; + left: 0; + } +} diff --git a/blocks/side-modal/side-modal.js b/blocks/side-modal/side-modal.js new file mode 100644 index 00000000..1835ba28 --- /dev/null +++ b/blocks/side-modal/side-modal.js @@ -0,0 +1,34 @@ +/* eslint-disable import/prefer-default-export */ +import { + decorateSections, decorateBlocks, loadBlocks, decorateButtons, decorateIcons, loadCSS, +} from '../../scripts/aem.js'; +import { getEnvelope } from '../../scripts/apis/creg/creg.js'; + +export async function showSideModal(a) { + const { href } = a; + const listing = a.parentNode.parentNode.previousElementSibling.querySelector('div.address').id.split('-')[1]; + window.selectedListing = await getEnvelope(listing); + 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/icons/arrow-down.svg b/icons/arrow-down.svg new file mode 100644 index 00000000..4d8c6193 --- /dev/null +++ b/icons/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/arrow-up.svg b/icons/arrow-up.svg new file mode 100644 index 00000000..8edce9af --- /dev/null +++ b/icons/arrow-up.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/apis/creg/creg.js b/scripts/apis/creg/creg.js index ae43669d..ba7939cc 100644 --- a/scripts/apis/creg/creg.js +++ b/scripts/apis/creg/creg.js @@ -150,3 +150,19 @@ export async function getEconomicDetails(lat, long) { }); }); } + +/** + * Retrieves the envelope for a given listing ID. + * + * @param {string} listingId - The ID of the listing. + * @returns {Promise} A promise that resolves to the envelope data. + */ +export async function getEnvelope(listingId) { + return new Promise((resolve) => { + const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/envelope.js`, { type: 'module' }); + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ + listingId, + }); + }); +} diff --git a/scripts/apis/creg/workers/envelope.js b/scripts/apis/creg/workers/envelope.js new file mode 100644 index 00000000..768ed801 --- /dev/null +++ b/scripts/apis/creg/workers/envelope.js @@ -0,0 +1,19 @@ +/** + * Handle the Worker event. Fetches details for each provided listing id. + * + * @param {Object} event the worker event. + * @param {string} event.data.api the URL to fetch. + * @param {string[]} event.data.ids list of listing ids + */ +onmessage = async (event) => { + const { listingId } = event.data; + + try { + const response = await fetch(`/bin/bhhs/CregPropertySearchServlet?SearchType=Envelope&ListingId=${listingId}`); + const data = response.ok ? await response.json() : undefined; + + postMessage(data); + } catch (error) { + postMessage({}); + } +}; 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/dom-helpers.js b/scripts/dom-helpers.js index 1fce908c..7059545f 100644 --- a/scripts/dom-helpers.js +++ b/scripts/dom-helpers.js @@ -97,3 +97,9 @@ export function article(...items) { return domEl('article', ...items); } export function strong(...items) { return domEl('strong', ...items); } export function select(...items) { return domEl('select', ...items); } export function option(...items) { return domEl('option', ...items); } +export function table(...items) { return domEl('table', ...items); } +export function tbody(...items) { return domEl('tbody', ...items); } +export function th(...items) { return domEl('th', ...items); } +export function thead(...items) { return domEl('thead', ...items); } +export function tr(...items) { return domEl('tr', ...items); } +export function td(...items) { return domEl('td', ...items); } 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..db887d1c 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -1,4 +1,5 @@ import { fetchPlaceholders } from './aem.js'; +import { div, domEl } from './dom-helpers.js'; /** * Creates the standard Spinner Div. @@ -6,10 +7,10 @@ import { fetchPlaceholders } from './aem.js'; * @returns {HTMLDivElement} the spinner div. */ export function getSpinner() { - const div = document.createElement('div'); - div.classList.add('loading-spinner'); - div.innerHTML = ''; - return div; + const spinner = document.createElement('div'); + spinner.classList.add('loading-spinner'); + spinner.innerHTML = ''; + return spinner; } /** @@ -49,6 +50,41 @@ export function showModal(content) { document.body.append(modal); } +let sideModal; +let focusElement; + +export function removeSideModal() { + if (!sideModal) return; + sideModal.parentNode.nextSibling.remove(); + sideModal.parentNode.remove(); + sideModal = null; + document.body.classList.remove('disable-scroll'); + if (focusElement) focusElement.focus(); +} + +export async function showSideModal(content, decorateContent) { + if (!sideModal) { + const temp = div( + domEl('aside', { class: 'side-modal' }, div()), + div({ class: 'side-modal-overlay' }), + ); + sideModal = temp.querySelector('.side-modal'); + document.body.append(temp); + } + 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 +116,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 +134,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 +194,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 0bf3eaed..82a72c53 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -318,6 +318,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, @@ -391,11 +405,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); @@ -549,7 +559,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;