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