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/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)); + }); +};