diff --git a/blocks/agent-profile/agent-profile.css b/blocks/agent-profile/agent-profile.css new file mode 100644 index 00000000..fa575e7f --- /dev/null +++ b/blocks/agent-profile/agent-profile.css @@ -0,0 +1,140 @@ +.agent-profile.block { + display: flex; + padding: 0 1rem; +} + +main .section.agent-profile-container .agent-profile-wrapper { + word-break: break-word; +} + +.agent-profile.block .profile-image img { + width: 9.375rem; + height: 11.875rem; +} + +.agent-profile.block .profile-content .contact-me { + margin-top: 1.5rem; + display: none; +} + +.agent-profile.block .profile-content { + padding-left: 1.5rem; + font-size: var(--body-font-size-s); +} + +.agent-profile.block .profile-content .name { + font-size: var(--body-font-size-xl); + line-height: var(--line-height-m); + margin-bottom: 0.5rem; +} + +.agent-profile.block .profile-content .designation, +.agent-profile.block .profile-content .license-number { + font-size: var(--body-font-size-xs); + text-transform: uppercase; + margin-bottom: 0.25rem; +} + +.agent-profile.block .profile-content .social ul { + display: flex; + margin-top: 1rem; + opacity: .5; +} + +.agent-profile.block .profile-content .social li { + margin-right: 0.5rem; +} + +.agent-profile.block .profile-content .email a, +.agent-profile.block .profile-content .website a { + font-size: var(--body-font-size-xs); + color: var(--black); + text-transform: lowercase; + word-wrap: break-word; +} + +.agent-profile.block .profile-content .contact-me 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; +} + +.agent-profile.block .profile-content .contact-me a:hover { + color: var(--primary-light); + background-color: var(--primary-color); +} + +.agent-profile.block .profile-content .website, +.agent-profile.block .profile-content .email { + margin-bottom: 0.25rem; +} + +.agent-profile.block .profile-content .phone { + font-size: var(--body-font-size-xs); +} + +.agent-profile.block .profile-content .phone li { + margin-bottom: 0.25rem; +} + +@media (min-width: 600px) { + .agent-profile.block .profile-content .contact-me { + display: block; + } +} + +@media (min-width: 1200px) { + main .section.agent-profile-container { + position: relative; + } + + main .section.agent-profile-container .agent-profile-wrapper { + position: absolute; + display: flex; + left: auto; + width: 34rem; + right: 0; + bottom: 4.625rem; + padding: 1.875rem 1.875rem 0; + z-index: 1; + background-color: var(--white); + } + + .agent-profile.block { + position: relative; + line-height: var(--line-height-s); + } + + .agent-profile.block .profile-image img { + width: 13.125rem; + height: 15.625rem; + } + + .agent-profile.block .profile-content .phone { + font-size: var(--body-font-size-s); + margin-top: 2px; + line-height: var(--line-height-m); + } + + .agent-profile.block .profile-content .email a, + .agent-profile.block .profile-content .website a { + font-size: var(--body-font-size-xs); + } + + .agent-profile.block .profile-content { + padding-left: 1.875rem; + } + + .agent-profile.block .profile-content .designation, + .agent-profile.block .profile-content .license-number { + margin-bottom: unset; + } + + .agent-profile.block .profile-content .phone li { + margin-bottom: unset; + } +} diff --git a/blocks/agent-profile/agent-profile.js b/blocks/agent-profile/agent-profile.js new file mode 100644 index 00000000..61f963b7 --- /dev/null +++ b/blocks/agent-profile/agent-profile.js @@ -0,0 +1,119 @@ +import { decorateIcons, getMetadata } from '../../scripts/aem.js'; +import { + a, div, h1, ul, li, img, span, +} from '../../scripts/dom-helpers.js'; + +const getPhoneDiv = () => { + let phoneDiv; + let phoneUl; + + if (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'))); + } + + if (phoneUl) { + phoneDiv = div({ class: 'phone' }); + phoneDiv.append(phoneUl); + return phoneDiv; + } + + return phoneDiv; +}; + +const getWebsiteDiv = () => { + let websiteDiv; + const websiteUrl = getMetadata('website'); + + if (websiteUrl) { + const text = 'my website'; + const anchor = a({ href: websiteUrl, title: text, 'aria-label': text }, text); + websiteDiv = div({ class: 'website' }, anchor); + } + + return websiteDiv; +}; + +const getEmailDiv = () => { + let emailDiv; + const agentEmail = getMetadata('email'); + + if (agentEmail) { + const anchor = a({ href: `mailto:${agentEmail}`, title: agentEmail, 'aria-label': agentEmail }, agentEmail); + emailDiv = div({ class: 'email' }, anchor); + } + + return emailDiv; +}; + +const getImageDiv = () => { + const agentPhoto = getMetadata('photo'); + return div({ class: 'profile-image' }, img({ src: agentPhoto, alt: getMetadata('name'), loading: 'lazy' })); +}; + +const getSocialDiv = () => { + const socialDiv = div({ class: 'social' }); + let socialUl; + + ['facebook', 'instagram', 'linkedin'].forEach((x) => { + const url = getMetadata(x); + socialUl = socialUl || ul({}); + if (url) { + const socialLi = li({}, a({ + href: url, class: x, title: x, 'aria-label': x, + }, span({ class: `icon icon-${x}` }))); + socialUl.append(socialLi); + } + }); + + if (socialUl) { + socialDiv.append(socialUl); + return socialDiv; + } + + return null; +}; + +export default async function decorate(block) { + const profileImage = getImageDiv(); + const profileContent = div({ class: 'profile-content' }, + div({ class: 'name' }, h1({}, getMetadata('name'))), + div({ class: 'designation' }, getMetadata('designation')), + ); + + const licenseNumber = getMetadata('license-number'); + if (licenseNumber) { + profileContent.append(div({ class: 'license-number' }, `LIC# ${licenseNumber}`)); + } + + const emailDiv = getEmailDiv(); + if (emailDiv) { + profileContent.append(emailDiv); + } + + const websiteDiv = getWebsiteDiv(); + if (websiteDiv) { + profileContent.append(websiteDiv); + } + + const phoneDiv = getPhoneDiv(); + if (phoneDiv) { + profileContent.append(phoneDiv); + } + + const contactMeText = 'Contact Me'; + profileContent.append(div({ class: 'contact-me' }, + a({ href: '#', title: contactMeText, 'aria-label': contactMeText }, contactMeText))); + + const socialDiv = getSocialDiv(); + if (socialDiv) { + profileContent.append(socialDiv); + } + decorateIcons(profileContent); + block.replaceChildren(profileImage, profileContent); +} diff --git a/blocks/economic-data/economic-data.css b/blocks/economic-data/economic-data.css new file mode 100644 index 00000000..79ad51ac --- /dev/null +++ b/blocks/economic-data/economic-data.css @@ -0,0 +1,176 @@ +.economic-data.block { + margin: 0; + padding: 20px; + box-sizing: border-box; +} + +.economic-data .accordion-header { + border-top: 1px solid var(--grey); + cursor: pointer; + padding: 16px 30px 16px 0; + position: relative; + display: inline-block; + font-family: var(--font-family-georgia); + font-weight: var(--font-weight-semibold); + line-height: 26px; + margin: 0 5px 0 0; + font-size: 22px; + width: 100%; +} + +.economic-data .accordion .accordion-header::after { + border-color: var(--body-color) transparent transparent transparent; + border-style: solid; + border-width: 6px 5px 0; + content: ''; + margin-top: -5px; + position: absolute; + right: 8px; + top: 50%; + transition: transform .3s linear; + transform: rotate(0); +} + +.economic-data .accordion .accordion-header:not(.active)::after { + transform: rotate(90deg); + transition: transform .3s linear; +} + +.economic-data .accordion-content { + display: none; + padding-bottom: 60px; +} + +.economic-data .accordion-header.active + .accordion-content { + display: block; +} + +.economic-data .container { + display: flex; + flex-direction: column; +} + +.economic-data .row { + display: flex; + flex-wrap: wrap; + border-bottom: 1px solid #ccc; + padding: 10px 0; +} + +.economic-data.block .accordion-content .row:last-child { + border-bottom: none; +} + +.economic-data .cell { + padding: 10px 10px 10px 0; + box-sizing: border-box; +} + +.economic-data .cell-header { + font-weight: bold; + text-transform: uppercase; + font-size: var(--body-font-size-xs); + line-height: var(--line-height-xs); + letter-spacing: var(--letter-spacing-xs); +} + +.economic-data .cell-1 { + width: 100%; + text-transform: uppercase; + font-size: 14px; +} + +.economic-data .cell-1.cell-header { + padding: 0; +} + +.economic-data .cell-2, .economic-data .cell-3, .economic-data .cell-4 { + width: 33.33%; +} + +.economic-data .progress-bar { + width: calc(100% - 60px); + background-color: #f3f3f3; + height: 5px; + margin-left: 50px; + margin-top: -12px; + position: relative; +} + +.economic-data .progress-owner, .progress-renter { + height: 100%; + position: absolute; + top: 0; +} + +.economic-data .progress-owner { + background-color: var(--primary-color); + left: 0; +} + +.economic-data .progress-renter { + background-color: var(--light-grey); + right: 0; +} + +.economic-data .tooltip { + position: relative; + display: inline-block; + height: 19px; + width: 19px; + margin-left: 5px; +} + +.economic-data .tooltip .icon-info-circle-dark { + display: none; +} + +.economic-data .tooltip .tooltiptext { + visibility: hidden; + width: 290px; + background-color: var(--black); + color: var(--white); + text-align: left; + padding: 14px 18px; + position: absolute; + z-index: 1; + top: 100%; + left: 0; + margin: 12px 0 0 -10px; + font-family: var(--font-family-proxima); + font-size: var(--body-font-size-s); + letter-spacing: var(--letter-spacing-s); + line-height: var(--line-height-s); +} + +.economic-data .tooltip:hover .icon-info-circle { + display: none; +} + +.economic-data .tooltip:hover .icon-info-circle-dark { + display: block; +} + +.economic-data .tooltip:hover .tooltiptext { + visibility: visible; +} + +.economic-data .tooltip .tooltiptext::before { + content: ''; + position: absolute; + bottom: 100%; + left: 8px; + border-width: 10px; + border-style: solid; + border-color: transparent transparent var(--black) transparent; +} + +@media (min-width: 900px) { + .economic-data .cell-1 { + width: 25%; + } + + .economic-data .cell-2, .economic-data .cell-3, .economic-data .cell-4 { + width: 25%; + } +} diff --git a/blocks/economic-data/economic-data.js b/blocks/economic-data/economic-data.js new file mode 100755 index 00000000..7b528b41 --- /dev/null +++ b/blocks/economic-data/economic-data.js @@ -0,0 +1,174 @@ +import { getDetails, getEconomicDetails } from '../../scripts/apis/creg/creg.js'; +import { div, span } from '../../scripts/dom-helpers.js'; +import { decorateIcons } from '../../scripts/aem.js'; + +const keys = [ + 'ListPriceUS', + 'StreetName', + 'City', + 'StateOrProvince', + 'PostalCode', + 'Latitude', + 'Longitude', + 'LotSizeAcres', + 'LotSizeSquareFeet', + 'LivingAreaUnits', + 'Media', + 'SmallMedia', + 'PropId', + 'OpenHouses', + 'CourtesyOf', +]; + +function pick(obj, ...args) { + return args.reduce((res, key) => ({ ...res, [key]: obj[key] }), { }); +} + +function toggleAccordion(event) { + const content = event.target; + content.classList.toggle('active'); +} + +/** + * Retrieves the property ID from the current URL path. + * @returns {string|null} The property ID if found in the URL path, or null if not found. + */ +function getPropIdFromPath() { + const url = window.location.pathname; + const match = url.match(/pid-(\d+)/); + if (match && match[1]) { + return match[1]; + } + return null; +} + +async function getPropertyByPropId(propId) { + const resp = await getDetails(propId); + return resp[0]; +} + +async function getSocioEconomicData(latitude, longitude) { + const resp = await getEconomicDetails(latitude, longitude); + return resp[0]; +} + +function getHeaderLabels(title) { + switch (title.toLowerCase()) { + case 'occupancy': + return 'Occupancy'; + case 'housing trends': + return 'Housing Trends'; + case 'economic data': + return 'Economic Data'; + default: + return 'Untitled'; + } +} + +function getColumnHeader(title, index) { + switch (title.toLowerCase()) { + case 'occupancy': + return ['Owned', 'Rented', 'Vacant'][index - 1]; + case 'housing trends': + return ['Home Appreciation', 'Median Age'][index - 1]; + case 'economic data': + return ['Median House. Income', 'Unemployment', 'Cost of Living Index'][index - 1]; + default: + return ''; + } +} + +function getDataValue(item, title, index) { + switch (title.toLowerCase()) { + case 'occupancy': + if (index === 1) { + return `${item.ownerOccupiedPercent}%`; + } + if (index === 2) { + return `${item.renterOccupiedPercent}%`; + } + return `${item.vacancyPercent}%`; + case 'housing trends': + if (index === 1) { + return `${item.homeValueAppreciationPercent}%`; + } + return `${item.medianHomeAge}`; + case 'economic data': + if (index === 1) { + return `${item.medianIncome}`; + } + return `${item.unemploymentPercent}%`; + default: + return ''; + } +} + +function generateDataTable(block, title, socioEconData) { + // Create the accordion structure + const accordion = div({ class: 'accordion' }, + div({ class: 'accordion-header', onclick: (e) => toggleAccordion(e) }, getHeaderLabels(title), div({ class: 'tooltip' }, + span({ class: 'icon icon-info-circle' }), + span({ class: 'icon icon-info-circle-dark' }), + span({ class: 'tooltiptext' }, `${socioEconData.citation}`), + ), + ), + div({ class: 'accordion-content' }, + div({ id: `${title.toLowerCase().replace(' ', '-')}-data-container`, class: 'container', role: 'grid' }), + ), + ); + block.appendChild(accordion); + + const container = document.getElementById(`${title.toLowerCase().replace(' ', '-')}-data-container`); + + // Create header row + const headerRow = div({ class: 'row', role: 'row' }, + div({ class: 'cell cell-1 cell-header', role: 'columnheader' }), + div({ class: 'cell cell-2 cell-header', role: 'columnheader' }, getColumnHeader(title, 1)), + div({ class: 'cell cell-3 cell-header', role: 'columnheader' }, getColumnHeader(title, 2)), + div({ class: 'cell cell-4 cell-header', role: 'columnheader' }, getColumnHeader(title, 3) ? getColumnHeader(title, 3) : ''), + ); + container.appendChild(headerRow); + + // Create data rows + socioEconData.data.forEach((item) => { + const dataRow = div({ class: 'row', role: 'row' }, + div({ class: 'cell cell-1', role: 'cell' }, + div({ role: 'presentation' }, `${item.level.charAt(0).toUpperCase() + item.level.slice(1)}: ${item.label}`), + ), + div({ class: 'cell cell-2', role: 'cell' }, + getDataValue(item, title, 1), + title.toLowerCase() === 'occupancy' + ? div({ class: 'progress-bar' }, + span({ class: 'progress-owner', style: `width: ${item.ownerOccupiedPercent}%` }), + span({ class: 'progress-renter', style: `width: ${100 - item.ownerOccupiedPercent}%` }), + ) : '', + ), + div({ class: 'cell cell-3', role: 'cell' }, getDataValue(item, title, 2)), + div({ class: 'cell cell-4', role: 'cell' }, title.toLowerCase() === 'housing trends' ? '' : getDataValue(item, title, 3)), + ); + + container.appendChild(dataRow); + }); +} + +export default async function decorate(block) { + let property = {}; + let propId = getPropIdFromPath(); // assumes the listing page pathname ends with the propId + // TODO: remove this test propId + if (!propId) propId = '370882966'; + + const propertyData = await getPropertyByPropId(propId); + if (propertyData) { + property = pick(propertyData, ...keys); + if (property.Latitude && property.Longitude) { + const socioEconData = await getSocioEconomicData(property.Latitude, property.Longitude); + if (socioEconData) { + generateDataTable(block, 'Occupancy', socioEconData); + generateDataTable(block, 'Housing Trends', socioEconData); + generateDataTable(block, 'Economic Data', socioEconData); + } + } + } + decorateIcons(block); + window.property = property; +} diff --git a/blocks/fragment/fragment.css b/blocks/fragment/fragment.css new file mode 100644 index 00000000..b3c58707 --- /dev/null +++ b/blocks/fragment/fragment.css @@ -0,0 +1 @@ +/* stylelint-disable-next-line no-empty-source */ diff --git a/blocks/fragment/fragment.js b/blocks/fragment/fragment.js new file mode 100644 index 00000000..71e40355 --- /dev/null +++ b/blocks/fragment/fragment.js @@ -0,0 +1,57 @@ +/* +* Fragment Block +* Include content on a page as a fragment. +* https://www.aem.live/developer/block-collection/fragment +*/ + +import { + decorateMain, +} from '../../scripts/scripts.js'; + +import { + loadBlocks, +} from '../../scripts/aem.js'; + +/** + * Loads a fragment. + * @param {string} path The path to the fragment + * @returns {HTMLElement} The root element of the fragment + */ +export async function loadFragment(path) { + if (path?.startsWith('/')) { + const resp = await fetch(`${path}.plain.html`); + if (resp.ok) { + const main = document.createElement('main'); + main.innerHTML = await resp.text(); + + // reset base path for media to fragment base + const resetAttributeBase = (tag, attr) => { + main.querySelectorAll(`${tag}[${attr}^="./media_"]`).forEach((elem) => { + elem[attr] = new URL(elem.getAttribute(attr), new URL(path, window.location)).href; + }); + }; + resetAttributeBase('img', 'src'); + resetAttributeBase('source', 'srcset'); + + decorateMain(main); + await loadBlocks(main); + return main; + } + } + return null; +} + +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/hero.js b/blocks/hero/hero.js index d2db95ec..7da31eb1 100644 --- a/blocks/hero/hero.js +++ b/blocks/hero/hero.js @@ -45,17 +45,15 @@ function rotateImage(images) { export default async function decorate(block) { // check if it has a video - const video = block.querySelector('a[href*=".mp4"]'); - const videoWrapper = video && video.closest('div'); - videoWrapper.classList.add('video-wrapper'); - const videoLink = videoWrapper?.firstElementChild; - // transform link into a video tag + const videoLink = block.querySelector('a[href*=".mp4"]'); + let videoWrapper; if (videoLink) { - const parent = videoLink.parentElement; + videoWrapper = document.createElement('div'); + videoWrapper.classList.add('video-wrapper'); const videoHref = videoLink.href; videoLink.remove(); setTimeout(() => { - decorateVideo(parent, videoHref); + decorateVideo(videoWrapper, videoHref); }, 3000); } diff --git a/blocks/info-mouseover/info-mouseover.js b/blocks/info-mouseover/info-mouseover.js index 25e679f5..10c170fa 100644 --- a/blocks/info-mouseover/info-mouseover.js +++ b/blocks/info-mouseover/info-mouseover.js @@ -16,7 +16,7 @@ export default async function decorate(block) { const heading = block.closest('.section').querySelector('h1,h2,h3,h4,h5,h6'); const icon = document.createElement('span'); - icon.classList.add('icon', 'icon-info_circle'); + icon.classList.add('icon', 'icon-info-circle'); positionIcon(heading, icon); block.append(icon); diff --git a/blocks/profile/profile.js b/blocks/profile/profile.js index 087c423f..d2437a7b 100644 --- a/blocks/profile/profile.js +++ b/blocks/profile/profile.js @@ -119,7 +119,7 @@ function showNotification(type, iconHtml, message, message2) { } function showError(err) { - showNotification('error', '', i18n('There was a problem processing your request.'), i18n(err)); + showNotification('error', '', i18n('There was a problem processing your request.'), i18n(err)); } function showSuccess(message) { diff --git a/icons/info_circle_dark.svg b/icons/info-circle-dark.svg similarity index 80% rename from icons/info_circle_dark.svg rename to icons/info-circle-dark.svg index 91c154dc..eefe9959 100644 --- a/icons/info_circle_dark.svg +++ b/icons/info-circle-dark.svg @@ -4,6 +4,7 @@ aria-hidden="true" tabindex="-1" viewBox="0 0 18 18"> + diff --git a/icons/info_circle.svg b/icons/info-circle.svg similarity index 100% rename from icons/info_circle.svg rename to icons/info-circle.svg diff --git a/scripts/apis/creg/creg.js b/scripts/apis/creg/creg.js index 50a93e5b..a7b940e9 100644 --- a/scripts/apis/creg/creg.js +++ b/scripts/apis/creg/creg.js @@ -67,3 +67,22 @@ export async function getDetails(...listingIds) { }); }); } + +/** + * Gets the economic details for the specified listing. + * + * @param {string} lat latitude + * @param {string} long longitude + * @return {Promise} resolving the economic details + */ +export async function getEconomicDetails(lat, long) { + return new Promise((resolve) => { + const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/economic.js`, { type: 'module' }); + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ + api: CREG_API_URL, + lat, + long, + }); + }); +} diff --git a/scripts/apis/creg/workers/economic.js b/scripts/apis/creg/workers/economic.js new file mode 100644 index 00000000..99fccffd --- /dev/null +++ b/scripts/apis/creg/workers/economic.js @@ -0,0 +1,20 @@ +/** + * Handle the Worker event. Fetches economic details for the provided lat/long. + * + * @param {Object} event the worker event. + * @param {string} event.data.api the URL to fetch. + * @param {string} event.data.lat latitude + * @param {string} event.data.long longitude + */ +onmessage = async (event) => { + const { api, lat, long } = event.data; + const promises = []; + promises.push( + fetch(`${api}/pdp/socioEconomicDataServlet?latitude=${lat}&longitude=${long}`) + .then((resp) => (resp.ok ? resp.json() : undefined)), + ); + + Promise.all(promises).then((values) => { + postMessage(values.filter((v) => v)); + }); +};