From a2dcd026301336ac92c601b24349d50da4d73f40 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Mon, 17 Jun 2024 08:47:07 -0600 Subject: [PATCH 01/13] get property --- blocks/property/property.css | 1 + blocks/property/property.js | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 blocks/property/property.css create mode 100644 blocks/property/property.js diff --git a/blocks/property/property.css b/blocks/property/property.css new file mode 100644 index 00000000..e329cdcb --- /dev/null +++ b/blocks/property/property.css @@ -0,0 +1 @@ +/* nothing yet */ \ No newline at end of file diff --git a/blocks/property/property.js b/blocks/property/property.js new file mode 100644 index 00000000..01b620ba --- /dev/null +++ b/blocks/property/property.js @@ -0,0 +1,37 @@ +import { getDetails } from '../../scripts/apis/creg/creg.js'; +import { a, div, img } from '../../scripts/dom-helpers.js'; + +/** + * 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; +} + +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 getDetails(propId)[0]; + if (propertyData) { + block.innerHTML = ''; + const row = div({ class: 'row' }, + div({ class: 'back' }, + a({ onclick: 'window.history.back()' }, 'Back'), + ), + div({ class: 'luxury-property' }, + img({ src: 'lux_mark.png', alt: 'Luxury Property' }), + ), + + ); + block.append(row); + } +} From d4226212048f23edae8ce826a8f5ee3e08a3e2a7 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Tue, 18 Jun 2024 07:43:02 -0600 Subject: [PATCH 02/13] start lining up content elements --- blocks/property/property.css | 41 ++++++++++++++++++- blocks/property/property.js | 78 ++++++++++++++++++++++++++++++------ icons/share-empty.svg | 9 +++++ scripts/util.js | 14 +++++++ 4 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 icons/share-empty.svg diff --git a/blocks/property/property.css b/blocks/property/property.css index e329cdcb..788cc391 100644 --- a/blocks/property/property.css +++ b/blocks/property/property.css @@ -1 +1,40 @@ -/* nothing yet */ \ No newline at end of file +/* nothing yet */ +.property.block .property-address { + font-size: var(--heading-font-size-l); + font-style: normal; + font-weight: 400; + letter-spacing: normal; + line-height: var(--line-height-m); + color: #2a2223; + font-family: var(--font-family-secondary); +} + +.property.block .property-specs { + font-family: var(--font-family-primary); + font-size: var(--body-font-size-xs); + font-style: normal; + font-weight: 400; + letter-spacing: normal; + line-height: var(--line-height-m); + color: #2a2223; +} + +.property.block .courtesy { + font-family: var(--font-family-primary); + font-size: var(--body-font-size-xxs); + font-style: normal; + font-weight: 400; + letter-spacing: normal; + line-height: var(--line-height-s); + color: var(--dark-grey); +} + +.property.block .property-price { + font-family: var(--font-family-primary); + font-size: var(--heading-font-size-l); + font-style: normal; + font-weight: var(--font-weight-semibold); + letter-spacing: normal; + line-height: var(--line-height-xs); + color: var(--black); +} diff --git a/blocks/property/property.js b/blocks/property/property.js index 01b620ba..e7eabfed 100644 --- a/blocks/property/property.js +++ b/blocks/property/property.js @@ -1,5 +1,7 @@ -import { getDetails } from '../../scripts/apis/creg/creg.js'; -import { a, div, img } from '../../scripts/dom-helpers.js'; +import { decorateIcons } from '../../scripts/aem.js'; +import { getEnvelope } from '../../scripts/apis/creg/creg.js'; +import { a, div, img, p, span } from '../../scripts/dom-helpers.js'; +import { formatCurrency, formatNumber } from '../../scripts/util.js'; /** * Retrieves the property ID from the current URL path. @@ -14,24 +16,76 @@ function getPropIdFromPath() { return null; } +async function getPropertyByPropId(propId) { + const resp = await getEnvelope(propId); + return resp; +} + 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 getDetails(propId)[0]; - if (propertyData) { - block.innerHTML = ''; - const row = div({ class: 'row' }, - div({ class: 'back' }, - a({ onclick: 'window.history.back()' }, 'Back'), + window.propertyData = await getPropertyByPropId(propId); + block.innerHTML = ''; +/* const row = div({ class: 'row' }, + div({ class: 'back' }, + a({ href: '#' }, 'Back'), + ), + div({ class: 'luxury-property' }, + img({ src: 'lux_mark.png', alt: 'Luxury Property' }), + ), + ); + block.append(row); */ + if (!window.propertyData) { + block.innerHTML = 'Property not found'; + } else { + const property = window.propertyData.propertyDetails; + const propertyMedia = property.photos; + const propertySmallMedia = property.smallPhotos; + const propertyPrice = formatCurrency(property.listPrice); + const propertyAddress = window.propertyData.addressLine1; + const propertyAddress2 = window.propertyData.addressLine2; + const rooms = property.bedroomsTotal + property.interiorFeatures.bathroomsTotal; + const bedBath = property.bedroomsTotal ? `${property.bedroomsTotal} bed / ${property.interiorFeatures.bathroomsTotal} bath` : ''; + const livingSpace = property.livingArea ? `${formatNumber(property.livingArea)} ${property.interiorFeatures.livingAreaUnits}` : ''; + const lotSF = property.lotSizeArea ? `${property.lotSizeArea} ${property.interiorFeatures.livingAreaUnits}` : ''; + const lotAcre = property.lotSizeAcres ? `${property.lotSizeAcres} acres lot size` : ''; + let propertySpecs = bedBath; + propertySpecs += rooms ? ` / ${livingSpace}` : livingSpace; + propertySpecs += rooms ? ` / ${lotSF}` : ''; + propertySpecs += property.lotSizeArea ? `, ${lotAcre}` : ''; + propertySpecs += propertySpecs.length ? ` / ${property.propertySubType}` : ''; + const propertyLatitude = property.Latitude; + const propertyLongitude = property.Longitude; + const propertyId = property.PropId; + const propertyOpenHouses = property.OpenHouses; + const propertyCourtesyOf = property.courtesyOf; + + const propertyDetails = div({ class: 'property-details' }, + div({ class: 'property-info' }, + div({ class: 'property-address' }, propertyAddress, document.createElement('br'), propertyAddress2), + div({ class: 'property-specs' }, propertySpecs), + div({ class: 'courtesy' }, propertyCourtesyOf), ), - div({ class: 'luxury-property' }, - img({ src: 'lux_mark.png', alt: 'Luxury Property' }), + div({ class: 'property-price' }, propertyPrice), + div({ class: 'save-share' }, + p({ class: 'button-container' }, + a({ href: '', 'aria-label': 'Save property listing', class: 'save button secondary' }, + span({ class: 'icon icon-heartempty' }), + 'Save', + ), + ), + p({ class: 'button-container' }, + a({ href: '', 'aria-label': 'Share property listing', class: 'share button secondary' }, + span({ class: 'icon icon-share-empty' }), + 'Share', + ), + ), ), - ); - block.append(row); + block.append(propertyDetails); + decorateIcons(block); } } diff --git a/icons/share-empty.svg b/icons/share-empty.svg new file mode 100644 index 00000000..be35c1b7 --- /dev/null +++ b/icons/share-empty.svg @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/scripts/util.js b/scripts/util.js index db887d1c..228c4652 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -189,6 +189,20 @@ export function phoneFormat(num) { return phoneNum; } +export function formatCurrency(amount) { + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }); + return formatter.format(amount); +} + +export function formatNumber(num) { + const formatter = new Intl.NumberFormat('en-US'); + return formatter.format(num); +} + const Util = { getSpinner, showModal, From 74260f66e15f518e607d676d0062b711dac17137 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Tue, 18 Jun 2024 13:28:04 -0600 Subject: [PATCH 03/13] layout adjusted for screen sizes --- blocks/property/property.css | 100 ++++++++++++++++++++++++++++++++++- blocks/property/property.js | 33 ++++++------ scripts/util.js | 4 +- 3 files changed, 116 insertions(+), 21 deletions(-) diff --git a/blocks/property/property.css b/blocks/property/property.css index 788cc391..83417ac0 100644 --- a/blocks/property/property.css +++ b/blocks/property/property.css @@ -1,4 +1,40 @@ -/* nothing yet */ +.property.block .property-details { + display: flex; + flex-wrap: wrap; + padding: 15px 0 20px 0; +} + +.property.block .backnav { + flex: 0 0 100%; + max-width: 100%; +} + +.property.block .back a { + font-family: var(--font-family-primary); + font-size: 12px; + font-style: normal; + font-weight: 400; + letter-spacing: normal; + line-height: 130%; + color: #2a2223; + margin-bottom: 25px; + text-transform: uppercase; +} + +.property.block .property-details > .save-share { + order: 0; + display: flex; + justify-content: flex-end; + gap: 10px; + width: 100%; +} + +.property.block .property-details > .property-info { + order: 2; + flex: 0 0 100%; + max-width: 100%; +} + .property.block .property-address { font-size: var(--heading-font-size-l); font-style: normal; @@ -29,7 +65,8 @@ color: var(--dark-grey); } -.property.block .property-price { +.property.block .property-details > .property-price { + order: 3; font-family: var(--font-family-primary); font-size: var(--heading-font-size-l); font-style: normal; @@ -37,4 +74,63 @@ letter-spacing: normal; line-height: var(--line-height-xs); color: var(--black); + margin-top: 1rem; } + +.property.block .button-container { + margin-bottom: 0; +} + +.property.block .button-container a, +.property.block .button-container a:hover { + color: #2a2223; + border: 1px solid var(--grey); + padding: 5px 15px; + background-color: var(--white); +} + +.property.block .button-container .icon-heartempty, +.property.block .button-container .icon-share-empty { + margin-right: 8px; +} + +.property.block .button-container .icon-heartempty img, +.property.block .button-container .icon-share-empty img{ + width: 23px; + height: 23px; + vertical-align: middle; +} + +@media (min-width: 600px) { + .property.block .property-details > .property-price { + flex: 0 0 33.333%; + max-width: 33.333%; + margin-top: 0; + line-height: var(--line-height-m); + padding-left: 15px; + } + + .property.block .property-details > .property-info { + flex: 0 0 66.667%; + max-width: 66.667%; + padding-right: 15px; + } +} + +@media (min-width: 900px) { + .property.block .property-details > .save-share { + flex: 0 0 28%; + max-width: 28%; + order: 4; + } + + .property.block .property-details > .property-price { + flex: 0 0 25%; + max-width: 25%; + } + + .property.block .property-details > .property-info { + flex: 0 0 47%; + max-width: 47%; + } +} \ No newline at end of file diff --git a/blocks/property/property.js b/blocks/property/property.js index e7eabfed..dcf9e587 100644 --- a/blocks/property/property.js +++ b/blocks/property/property.js @@ -29,38 +29,24 @@ export default async function decorate(block) { window.propertyData = await getPropertyByPropId(propId); block.innerHTML = ''; -/* const row = div({ class: 'row' }, - div({ class: 'back' }, - a({ href: '#' }, 'Back'), - ), - div({ class: 'luxury-property' }, - img({ src: 'lux_mark.png', alt: 'Luxury Property' }), - ), - ); - block.append(row); */ + if (!window.propertyData) { block.innerHTML = 'Property not found'; } else { const property = window.propertyData.propertyDetails; - const propertyMedia = property.photos; - const propertySmallMedia = property.smallPhotos; const propertyPrice = formatCurrency(property.listPrice); const propertyAddress = window.propertyData.addressLine1; const propertyAddress2 = window.propertyData.addressLine2; const rooms = property.bedroomsTotal + property.interiorFeatures.bathroomsTotal; const bedBath = property.bedroomsTotal ? `${property.bedroomsTotal} bed / ${property.interiorFeatures.bathroomsTotal} bath` : ''; const livingSpace = property.livingArea ? `${formatNumber(property.livingArea)} ${property.interiorFeatures.livingAreaUnits}` : ''; - const lotSF = property.lotSizeArea ? `${property.lotSizeArea} ${property.interiorFeatures.livingAreaUnits}` : ''; - const lotAcre = property.lotSizeAcres ? `${property.lotSizeAcres} acres lot size` : ''; + const lotSF = property.lotSizeSquareFeet ? `${formatNumber(property.lotSizeSquareFeet)} ${property.interiorFeatures.livingAreaUnits}` : ''; + const lotAcre = property.lotSizeAcres ? `${formatNumber(property.lotSizeAcres, 2)} acres lot size` : ''; let propertySpecs = bedBath; propertySpecs += rooms ? ` / ${livingSpace}` : livingSpace; propertySpecs += rooms ? ` / ${lotSF}` : ''; propertySpecs += property.lotSizeArea ? `, ${lotAcre}` : ''; propertySpecs += propertySpecs.length ? ` / ${property.propertySubType}` : ''; - const propertyLatitude = property.Latitude; - const propertyLongitude = property.Longitude; - const propertyId = property.PropId; - const propertyOpenHouses = property.OpenHouses; const propertyCourtesyOf = property.courtesyOf; const propertyDetails = div({ class: 'property-details' }, @@ -86,6 +72,19 @@ export default async function decorate(block) { ), ); block.append(propertyDetails); + + if (property.isLuxury) { + const luxury = div({ class: 'luxury' }, + img({ src: '/icons/lux_mark_classic_blk.svg', alt: 'Luxury Property' }), + ); + propertyDetails.prepend(luxury); + } + const nav = div({ class: 'backnav' }, + div({ class: 'back' }, + a({ href: '#' }, 'Back'), + ), + ); + propertyDetails.prepend(nav); decorateIcons(block); } } diff --git a/scripts/util.js b/scripts/util.js index 228c4652..c03f2348 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -198,8 +198,8 @@ export function formatCurrency(amount) { return formatter.format(amount); } -export function formatNumber(num) { - const formatter = new Intl.NumberFormat('en-US'); +export function formatNumber(num, precision = 0) { + const formatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: precision }); return formatter.format(num); } From e3506ed105f41b3f7f597347d7ef5399ec227f72 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Tue, 18 Jun 2024 13:52:30 -0600 Subject: [PATCH 04/13] linting --- blocks/property/property.css | 12 ++++++------ blocks/property/property.js | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/blocks/property/property.css b/blocks/property/property.css index 83417ac0..c907f47f 100644 --- a/blocks/property/property.css +++ b/blocks/property/property.css @@ -1,15 +1,15 @@ .property.block .property-details { display: flex; flex-wrap: wrap; - padding: 15px 0 20px 0; + padding: 15px 0 20px; } -.property.block .backnav { +.property.block .property-details .backnav { flex: 0 0 100%; max-width: 100%; } -.property.block .back a { +.property.block .property-details .back a { font-family: var(--font-family-primary); font-size: 12px; font-style: normal; @@ -35,7 +35,7 @@ max-width: 100%; } -.property.block .property-address { +.property.block .property-info .property-address { font-size: var(--heading-font-size-l); font-style: normal; font-weight: 400; @@ -45,7 +45,7 @@ font-family: var(--font-family-secondary); } -.property.block .property-specs { +.property.block .property-info .property-specs { font-family: var(--font-family-primary); font-size: var(--body-font-size-xs); font-style: normal; @@ -55,7 +55,7 @@ color: #2a2223; } -.property.block .courtesy { +.property.block .property-info .courtesy { font-family: var(--font-family-primary); font-size: var(--body-font-size-xxs); font-style: normal; diff --git a/blocks/property/property.js b/blocks/property/property.js index dcf9e587..aed6079d 100644 --- a/blocks/property/property.js +++ b/blocks/property/property.js @@ -1,6 +1,8 @@ import { decorateIcons } from '../../scripts/aem.js'; import { getEnvelope } from '../../scripts/apis/creg/creg.js'; -import { a, div, img, p, span } from '../../scripts/dom-helpers.js'; +import { + a, div, img, p, span, +} from '../../scripts/dom-helpers.js'; import { formatCurrency, formatNumber } from '../../scripts/util.js'; /** From 5a8e4281e272740e671c742693fce8cd6e2529ac Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Tue, 18 Jun 2024 13:57:35 -0600 Subject: [PATCH 05/13] linting --- blocks/property/property.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/blocks/property/property.css b/blocks/property/property.css index c907f47f..36539304 100644 --- a/blocks/property/property.css +++ b/blocks/property/property.css @@ -81,21 +81,21 @@ margin-bottom: 0; } -.property.block .button-container a, -.property.block .button-container a:hover { +.property.block .property-details .button-container a, +.property.block .property-details .button-container a:hover { color: #2a2223; border: 1px solid var(--grey); padding: 5px 15px; background-color: var(--white); } -.property.block .button-container .icon-heartempty, -.property.block .button-container .icon-share-empty { +.property.block .property-details .button-container .icon-heartempty, +.property.block .property-details .button-container .icon-share-empty { margin-right: 8px; } -.property.block .button-container .icon-heartempty img, -.property.block .button-container .icon-share-empty img{ +.property.block .property-details .button-container .icon-heartempty img, +.property.block .property-details .button-container .icon-share-empty img{ width: 23px; height: 23px; vertical-align: middle; From dfe132943ce2bf6eb5192cc843a163b07a5c7815 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Tue, 25 Jun 2024 17:12:09 -0600 Subject: [PATCH 06/13] remarks and agent --- blocks/property/property.css | 157 +++++++++++++++++++++++++++++++++++ blocks/property/property.js | 97 +++++++++++++++++++++- scripts/util.js | 16 ++++ 3 files changed, 266 insertions(+), 4 deletions(-) diff --git a/blocks/property/property.css b/blocks/property/property.css index 36539304..46d9a6f4 100644 --- a/blocks/property/property.css +++ b/blocks/property/property.css @@ -1,3 +1,11 @@ +.property.block .details hr { + background-color: var(--secondary-accent); + height: 1px; + border: 0; + box-shadow: none; + margin: 2rem 0; +} + .property.block .property-details { display: flex; flex-wrap: wrap; @@ -101,6 +109,129 @@ vertical-align: middle; } +.property.block .details-description .notes-header { + font-size: var(--heading-font-size-m); + line-height: var(--line-height-m); + font-weight: var(--font-weight-bold); + color: var(--body-color); + letter-spacing: var(--letter-spacing-xs); +} + +.property.block .details-description .notes-body > div { + font-size: var(--body-font-size-xs); + line-height: var(--line-height-m); + background-color: #f3f2f2; + color: #463f40; + padding: 15px 20px; + margin: 20px 0; +} + +.property.block .details-description .remarks { + margin-bottom: 40px; +} +.property.block .details-description .remarks > div { + height: 149px; + font-size: var(--body-font-size-xs); + line-height: var(--line-height-m); + color: var(--body-color); + overflow: hidden; +} + +.property.block .details-description .remarks .view { + font-weight: var(--font-weight-bold); + color: var(--body-color); + font-size: var(--body-font-size-xs); + text-decoration: underline; +} + +.property.block .details-description .remarks .view.more::after { + content: ">"; + display: inline-block; + margin-left: 5px; +} + +.property.block .details-description .remarks .view.less::before { + content: "<"; + display: inline-block; + margin-right: 5px; +} + +.property.block .contact-details { + display: flex; + flex-wrap: wrap; + color: var(--body-color); +} + +.property.block .contact-details .profile { + margin-right: 16px; +} + +.property.block .contact-details hr { + background-color: var(--secondary-accent); + height: 1px; + border: 0; + box-shadow: none; + margin: 10px 0; + width: 130px; +} + +.property.block .company-name a { + font-size: var(--heading-font-size-s); + line-height: var(--line-height-xs); + font-weight: var(--font-weight-bold); + color: var(--body-color); + text-decoration: none; +} + +.property.block .company-name .title, +.property.block .company-email a, +.property.block .company-phone, +.property.block .company-phone a { + font-size: var(--body-font-size-xs); + color: var(--body-color); + margin-bottom: 5px; +} + +.property.block .company-name .title { + text-transform: uppercase; + margin-top: 8px; + font-size: var(--body-font-size-xxs); +} + +.property.block .company-email { + padding-bottom: 5px; +} + +.property.block .cta { + display: flex; + flex-direction: row; + gap: .5em; + width: 100%; +} + +.property.block .cta .button-container:not(:first-child) { + width: 100%; +} + +.property.block .cta button { + font-weight: var(--font-weight-bold); + font-family: var(--font-family-primary); + border: 1px solid #000; + line-height: var(--line-height-m); + letter-spacing: var(--letter-spacing-s); + text-transform: uppercase; + background-color: var(--primary-color); + color: var(--tertiary-color); + font-size: var(--body-font-size-xs); + width: 100%; + font-weight: var(--font-weight-bold); + padding: 6px 8px; + height: auto; +} +.property.block .cta .contact { + display: none; +} + @media (min-width: 600px) { .property.block .property-details > .property-price { flex: 0 0 33.333%; @@ -133,4 +264,30 @@ flex: 0 0 47%; max-width: 47%; } + + .property.block .row { + display: flex; + flex-wrap: wrap; + gap: 15px; + width: 100%; + } + + .property.block .row .details { + flex: 0 0 66.667%; + max-width: 66.667%; + margin-right: 10px; + } + + .property.block .row .contact-details { + flex: 0 0 33.333%; + max-width: 33.333%; + } + + .property.block #remark-content { + height: auto; + } + + .property.block .details-description .remarks > a { + display: none; + } } \ No newline at end of file diff --git a/blocks/property/property.js b/blocks/property/property.js index aed6079d..15d7f020 100644 --- a/blocks/property/property.js +++ b/blocks/property/property.js @@ -1,9 +1,25 @@ -import { decorateIcons } from '../../scripts/aem.js'; +import { decorateIcons, loadBlock } from '../../scripts/aem.js'; import { getEnvelope } from '../../scripts/apis/creg/creg.js'; import { - a, div, img, p, span, + a, button, div, domEl, img, p, span, } from '../../scripts/dom-helpers.js'; -import { formatCurrency, formatNumber } from '../../scripts/util.js'; +import { formatCurrency, formatNumber, getImageURL } from '../../scripts/util.js'; + +function toggleHeight() { + const content = document.getElementById('remark-content'); + const link = document.querySelector('.remarks .view'); + if (content.style.height === '149px') { + content.style.height = 'auto'; + link.textContent = 'View Less'; + link.classList.remove('more'); + link.classList.add('less'); + } else { + content.style.height = '149px'; + link.textContent = 'View More'; + link.classList.remove('less'); + link.classList.add('more'); + } +} /** * Retrieves the property ID from the current URL path. @@ -27,7 +43,7 @@ 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'; + if (!propId) propId = '358207023'; // commercial '368554873'; // '375215759'; // luxury '358207023'; window.propertyData = await getPropertyByPropId(propId); block.innerHTML = ''; @@ -88,5 +104,78 @@ export default async function decorate(block) { ); propertyDetails.prepend(nav); decorateIcons(block); + + // Load the carousel slider block + /* const carousel = await loadBlock('carousel-slider'); + block.append(carousel); */ + + // create contact info + const description = div({ class: 'details-description' }, + div({ class: 'row' }, + div({ class: 'details' }, + div({ class: 'notes' }, + div({ class: 'notes-header' }, 'Your Notes'), + div({ class: 'notes-body' }, + div({ class: 'placeholder' }, 'To add notes, please save this property.'), + ), + ), + domEl('hr'), + div({ class: 'remarks' }, + div({ id: 'remark-content'}, property.publicRemarks), + a({ + href: '#', rel: 'noopener', class: 'view more', onclick: toggleHeight, + }, 'View More'), + ), + ), + div({ class: 'contact-details' }, + div({ class: 'contact-info' }, + div({ class: 'company-name' }, 'Berkshire Hathaway HomeServices', domEl('br'), 'Commonwealth Real Estate'), + domEl('hr', { width: '130px', height: '1px', textAlign: 'left' }), + div({ class: 'company-email' }, a({ href: 'mailto:realestateinquiry@commonmoves.com' }, 'realestateinquiry@commonmoves.com')), + div({ class: 'company-phone' }, a({ href: 'tel:(508) 810-0700' }, '(508) 810-0700')), + ), + div({ class: 'cta' }, + p({ class: 'button-container' }, button({ class: 'contact', href: '/fragments/contact-property-form' }, 'Contact')), + p({ class: 'button-container' }, button({ href: '/fragments/contact-property-form' }, 'See the property')), + p({ class: 'button-container' }, button({ href: '/fragments/contact-property-form' }, 'Make an offer')), + ), + ), + ), + ); + block.append(description); + + if (property.listAgentCd) { + const agent = window.propertyData.listAgent.reAgentDetail; + const info = block.querySelector('.contact-info'); + const pic = getImageURL(agent.image); + const profile = div({ class: 'profile' }, img({ src: pic, alt: agent.name, width: '82px' })); + info.insertAdjacentElement('beforebegin', profile); + const name = block.querySelector('.company-name'); + const link = a({ href: '#' }, agent.name); // TODO: add link to agent profile + name.replaceChildren(link); + if (agent.jobTitle) { + name.append(div({ class: 'title' }, JSON.parse(agent.jobTitle))); + } + if (agent.team) { + // name.append(div({ class: 'team' }, agent.team)); + } + const email = block.querySelector('.company-email a'); + email.textContent = agent.email; + email.href = `mailto:${agent.email}`; + const label = block.querySelector('.company-phone'); + label.prepend('Direct: '); + const phone = block.querySelector('.company-phone a'); + phone.textContent = agent.telephone; + phone.href = `tel:${agent.telephone}`; + } + + // new section with property details + // disclaimer + // market trends + // calculator + // schools + // Occupancy + // housing trends + // load economic data block } } diff --git a/scripts/util.js b/scripts/util.js index c03f2348..99d5038b 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -152,6 +152,22 @@ export function getCookieValue(cookieName) { return null; } +export 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 +} + /** * Format a provided value to a shorthand number. * From: https://reacthustle.com/blog/how-to-convert-number-to-kmb-format-in-javascript From 01b4132f110816567f81e56dd59fd830db4661c0 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Fri, 28 Jun 2024 11:46:33 -0600 Subject: [PATCH 07/13] property details accordions --- .../property-attributes.css | 143 ++++++++++++++++ .../property-attributes.js | 154 ++++++++++++++++++ .../property-carousel/property-carousel.css | 42 +++++ blocks/property-carousel/property-carousel.js | 42 +++++ 4 files changed, 381 insertions(+) create mode 100755 blocks/property-attributes/property-attributes.css create mode 100755 blocks/property-attributes/property-attributes.js create mode 100755 blocks/property-carousel/property-carousel.css create mode 100755 blocks/property-carousel/property-carousel.js diff --git a/blocks/property-attributes/property-attributes.css b/blocks/property-attributes/property-attributes.css new file mode 100755 index 00000000..8ced4175 --- /dev/null +++ b/blocks/property-attributes/property-attributes.css @@ -0,0 +1,143 @@ +.section.property-attributes-container { + width: var(--full-page-width); + background-color: var(--tertiary-color); +} + +.property-attributes.block .title { + font-size: var(--heading-font-size-l); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-s); + color: var(--primary-color); + width: 100%; + margin-left: 16px; + padding-top: 30px; + margin-bottom: 40px; +} + +.property-attributes.block .attributes .table, +.property-attributes.block .accordion .table { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + padding: 0 16px 20px 16px; +} + +.property-attributes.block .attributes .label, +.property-attributes.block .accordion .label { + font-size: var(--body-font-size-xs); + line-height: var(--line-height-m); + color: var(--secondary-color); +} + +.property-attributes.block .attributes .td, +.property-attributes.block .accordion .td { + font-size: var(--body-font-size-s); + line-height: var(--line-height-m); + color: var(--body-color); +} + +.property-attributes.block .tooltip { + position: relative; + display: inline-block; + height: 19px; + width: 19px; + margin-left: 5px; +} + +.property-attributes.block .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-size: var(--body-font-size-xxs); + letter-spacing: var(--letter-spacing-s); + line-height: var(--line-height-s); +} + +.property-attributes.block .tooltip:hover .tooltiptext { + visibility: visible; +} + +.property-attributes.block .tooltip .tooltiptext::before { + content: ''; + position: absolute; + bottom: 100%; + left: 8px; + border-width: 10px; + border-style: solid; + border-color: transparent transparent var(--black) transparent; +} + +.property-attributes.block .accordion-header { + font-size: var(--heading-font-size-xxs); + line-height: var(--line-height-s); + letter-spacing: var(--letter-spacing-s); + text-transform: uppercase; + cursor: pointer; + padding: 10px 16px; + margin: 10px 0; + border-top: 1px solid var(--secondary-medium-grey); + font-weight: var(--font-weight-bold); +} + +.property-attributes.block .accordion-content { + display: none; + overflow: hidden; +} + +.property-attributes.block .accordion-header.active + .accordion-content { + display: block; +} + +.property-attributes.block .accordions .accordion-header::after { + border-color: var(--body-color) transparent transparent transparent; + border-style: solid; + border-width: 6px 5px 0; + content: ''; + position: absolute; + right: 15px; + transition: transform .2s linear; + transform: rotate(0); +} + +.property-attributes.block .accordions .accordion-header:not(.active)::after { + transform: rotate(90deg); +} + +div.idxDisclaimer { + font-size: var(--body-font-size-xxs); + line-height: var(--line-height-s); + color: var(--dark-grey); + padding: 0 15px 20px 15px; + background-color: var(--white); +} + +@media (min-width: 900px) { + .property-attributes.block .attributes { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + } + + .property-attributes.block .accordion-content { + display: block; + } + + .property-attributes.block .accordion-header, + .property-attributes.block .accordions .accordion-header::after { + border: none; + } + + .property-attributes.block .accordion-content .table { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + } +} \ No newline at end of file diff --git a/blocks/property-attributes/property-attributes.js b/blocks/property-attributes/property-attributes.js new file mode 100755 index 00000000..128baa34 --- /dev/null +++ b/blocks/property-attributes/property-attributes.js @@ -0,0 +1,154 @@ +import { + div, domEl, span, +} from '../../scripts/dom-helpers.js'; +import { + formatNumber, phoneFormat, formatCurrency, +} from '../../scripts/util.js'; +import { decorateIcons } from '../../scripts/aem.js'; + +function toggleAccordion(event) { + const content = event.target; + content.classList.toggle('active'); +} + +export function formatListToHTML(str) { + const strParts = str.split(',').map((part) => part.trim()); + + const strElements = []; + strParts.forEach((part, index) => { + if (index > 0) { + strElements.push(document.createElement('br')); + } + strElements.push(part); // Push the trimmed string directly + }); + + return div({ class: 'td' }, ...strElements); +} + +export default async function decorate(block) { + block.innerHTML = ''; + + if (!window.propertyData) { + block.innerHTML = 'Property not found'; + } else { + const property = window.propertyData.propertyDetails; + const lotSF = property.lotSizeSquareFeet ? `${formatNumber(property.lotSizeSquareFeet)} ${property.interiorFeatures.livingAreaUnits}` : ''; + + const title = div({ class: 'title' }, 'Property Details'); + const details = div({ class: 'attributes' }, + div({ class: 'table' }, + div({ class: 'label' }, 'Type'), + div({ class: 'td' }, property.propertySubType), + div({ class: 'label' }, 'Status'), + div({ class: 'td' }, property.mlsStatus), + div({ class: 'label' }, 'County'), + div({ class: 'td' }, property.countyOrParish), + div({ class: 'label' }, 'Year built'), + div({ class: 'td' }, property.yearBuilt), + div({ class: 'label' }, 'Beds'), + div({ class: 'td' }, property.bedroomsTotal), + div({ class: 'label' }, 'Full Baths'), + div({ class: 'td' }, property.bathroomsFull), + div({ class: 'label' }, 'Half Baths'), + div({ class: 'td' }, property.bathroomsHalf), + ), + div({ class: 'table' }, + div({ class: 'label' }, 'Sq. Ft.'), + div({ class: 'td' }, formatNumber(property.livingArea)), + div({ class: 'label' }, 'Lot Size'), + div({ class: 'td' }, `${lotSF}, ${formatNumber(property.lotSizeAcres, 2)} acres`), + div({ class: 'label' }, 'Listing Id'), + div({ class: 'td' }, property.listingId), + div({ class: 'label' }, 'Courtesy Of'), + div({ class: 'td' }, `${property.courtesyOf} ${phoneFormat(property.listAgentPreferredPhone || property.listOfficePhone)}`), + div({ class: 'label' }, 'List Office Phone'), + div({ class: 'td' }, phoneFormat(property.listOfficePhone)), + div({ class: 'label' }, 'Buyer Agency Commission', div({ class: 'tooltip' }, + span({ class: 'icon icon-info-circle-dark' }), + span({ class: 'tooltiptext' }, + 'If the Buyer Agency Compensation provided for this listing is unclear, please contact the brokerage for more info.'), + ), + ), + div({ class: 'td' }, property.buyerAgentCommission), + ), + ); + const features = div({ class: 'accordions' }, + div({ class: 'accordion' }, // we might have to generate this dynamically + div({ class: 'accordion-header', onclick: (e) => toggleAccordion(e) }, 'Interior Features'), + div({ class: 'accordion-content' }, + div({ class: 'table' }, + div({ class: 'label' }, 'Fireplaces Total'), + div({ class: 'td' }, property.interiorFeatures.fireplaceFeatures || 0), + div({ class: 'label' }, 'Flooring'), + div({ class: 'td' }, formatListToHTML(property.interiorFeatures.flooring)), + div({ class: 'label' }, 'Living Area Units'), + div({ class: 'td' }, property.interiorFeatures.livingAreaUnits), + div({ class: 'label' }, 'Rooms Total'), + div({ class: 'td' }, property.bedroomsTotal + property.interiorFeatures.bathroomsTotal), + div({ class: 'label' }, 'Full Baths'), + div({ class: 'td' }, property.bathroomsFull), + div({ class: 'label' }, 'Half Baths'), + div({ class: 'td' }, property.bathroomsHalf), + div({ class: 'label' }, 'Bathrooms Total'), + div({ class: 'td' }, property.interiorFeatures.bathroomsTotal), + ), + ), + ), + div({ class: 'accordion' }, // we might have to generate this dynamically + div({ class: 'accordion-header', onclick: (e) => toggleAccordion(e) }, 'Exterior Features'), + div({ class: 'accordion-content' }, + div({ class: 'table' }, + div({ class: 'label' }, 'Lot/Land Description'), + div({ class: 'td' }, property.interiorFeatures.description || 0), + div({ class: 'label' }, 'Foundation'), + div({ class: 'td' }, property.interiorFeatures.foundation), + div({ class: 'label' }, 'Parking Spaces'), + div({ class: 'td' }, property.interiorFeatures.parkingFeatures), + ), + ), + ), + div({ class: 'accordion' }, // we might have to generate this dynamically + div({ class: 'accordion-header', onclick: (e) => toggleAccordion(e) }, 'Utility & Building Info'), + div({ class: 'accordion-content' }, + div({ class: 'table' }, + div({ class: 'label' }, 'Sewer'), + div({ class: 'td' }, property.utilityAndBuilding.sewer), + div({ class: 'label' }, 'Parcel Number'), + div({ class: 'td' }, property.utilityAndBuilding.parcelNumber), + div({ class: 'label' }, 'Cooling'), + div({ class: 'td' }, property.utilityAndBuilding.cooling), + div({ class: 'label' }, 'Water Source'), + div({ class: 'td' }, property.utilityAndBuilding.waterSource), + div({ class: 'label' }, 'Heating'), + div({ class: 'td' }, formatListToHTML(property.utilityAndBuilding.heating)), + div({ class: 'label' }, 'Tax Amount'), + div({ class: 'td' }, property.utilityAndBuilding.YrPropTax), + div({ class: 'label' }, 'Building Area Total'), + div({ class: 'td' }, formatNumber(property.utilityAndBuilding.buildingAreaTotal)), + div({ class: 'label' }, 'Price Per Sq Ft'), + div({ class: 'td' }, formatCurrency(property.utilityAndBuilding.pricePerSqFt)), + div({ class: 'label' }, 'Architectural Style'), + div({ class: 'td' }, property.utilityAndBuilding.architecturalStyle), + ), + ), + ), + ); + + const disclaimer = div({ class: 'idxDisclaimer' }, + domEl('hr'), + property.idxDisclaimer, + ); + + block.append(title, details, features); + block.parentNode.parentNode.insertAdjacentElement('afterEnd', disclaimer); + } + + decorateIcons(block); + // disclaimer + // market trends + // calculator + // schools + // Occupancy + // housing trends + // load economic data block +} diff --git a/blocks/property-carousel/property-carousel.css b/blocks/property-carousel/property-carousel.css new file mode 100755 index 00000000..2c47dfc7 --- /dev/null +++ b/blocks/property-carousel/property-carousel.css @@ -0,0 +1,42 @@ +/* */ +/* CSS: Responsive Styles for Image Carousel */ +.property-carousel .carousel { + position: relative; + max-width: 100%; + margin: auto; + overflow: hidden; +} + +.property-carousel .carousel-slide { + display: none; +} + +.property-carousel .carousel-slide img { + width: 100%; + height: auto; +} + +.property-carousel .prev, .property-carousel .next { + cursor: pointer; + position: absolute; + top: 50%; + width: auto; + padding: 16px; + margin-top: -22px; + color: white; + font-weight: bold; + font-size: 18px; + transition: 0.6s ease; + border-radius: 0 3px 3px 0; + user-select: none; +} + +.property-carousel .next { + right: 0; + border-radius: 3px 0 0 3px; +} + +/* Responsive Layout for screens less than 600px wide */ +@media screen and (min-width: 600px) { + +} diff --git a/blocks/property-carousel/property-carousel.js b/blocks/property-carousel/property-carousel.js new file mode 100755 index 00000000..8eb1115d --- /dev/null +++ b/blocks/property-carousel/property-carousel.js @@ -0,0 +1,42 @@ +import { a, div, img } from '../../scripts/dom-helpers.js'; +import { getEnvelope } from '../../scripts/apis/creg/creg.js'; + +function builtCarousel(block) { + const galleryContent = div({ class: 'gallery-content' }, + div({ class: 'gallery' }), + ); + const galleryModal = div({ class: 'gallery-modal' }); + block.append(galleryContent, galleryModal); +} + +/** + * 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 getEnvelope(propId); + return resp; +} + +export default async function decorate(block) { + const propId = getPropIdFromPath(); + window.propertyData = await getPropertyByPropId(propId); + block.innerHTML = ''; + + // TODO: switch to use global propertyData + if (!window.propertyData) { + block.innerHTML = 'Property not found'; + } else { + const property = window.propertyData.propertyDetails; + builtCarousel(block); + } +} From 69e0a4da4ecd86920fa6cc7aa50db7440d6613df Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Fri, 28 Jun 2024 12:07:25 -0600 Subject: [PATCH 08/13] add carousel --- blocks/carousel-slider/carousel-slider.css | 113 +++++++ blocks/carousel-slider/carousel-slider.js | 307 ++++++++++++++++++ .../property-carousel/property-carousel.css | 42 --- blocks/property-carousel/property-carousel.js | 42 --- 4 files changed, 420 insertions(+), 84 deletions(-) create mode 100644 blocks/carousel-slider/carousel-slider.css create mode 100644 blocks/carousel-slider/carousel-slider.js delete mode 100755 blocks/property-carousel/property-carousel.css delete mode 100755 blocks/property-carousel/property-carousel.js diff --git a/blocks/carousel-slider/carousel-slider.css b/blocks/carousel-slider/carousel-slider.css new file mode 100644 index 00000000..51a8a982 --- /dev/null +++ b/blocks/carousel-slider/carousel-slider.css @@ -0,0 +1,113 @@ +.carousel-slider { + position: relative; + width: 100%; + margin-top: 150px; + overflow: hidden; +} + +.carousel-slider .slide-container { + display: flex; + gap: 5px; + transition: transform 0.5s ease; +} + +.carousel-slider .slide { + flex: 0 0 auto; + position: relative; + max-height: 440px; +} + +.carousel-slider .slide img { + width: 100%; + height: auto; + object-fit: cover; +} + +.carousel-slider .slide .new-listing { + position: absolute; + top: 16px; + left: 16px; + z-index: 2; + height: 24px; + font-size: var(--heading-font-size-xs); + padding: 3px 7px; + line-height: var(--line-height-m); + font-weight: var(--font-weight-semibold); + display: none; + background: var(--primary-color); + color: var(--white); + letter-spacing: var(--letter-spacing-m); + text-transform: uppercase; +} + +.carousel-slider .btns { + position: absolute; + top: 50%; + display: flex; + justify-content: space-between; +} + +.carousel-slider .btn-prev::before { + content: "\f053"; + font-family: var(--font-family-fontawesome); +} + +.carousel-slider .btn-next::before { + content: "\f054"; + font-family: var(--font-family-fontawesome); +} + +.carousel-slider .btn-prev, +.carousel-slider .btn-next { + background: #a9a9a990; + height: 40px; + width: 40px; + margin-top: -25px; + cursor: pointer; + font-size: 1.2rem; + color: black; + text-align: center; + line-height: 2.5rem; + transition: all 0.2s ease-in; +} + +.carousel-slider div.thumbs { + padding: 10px 0; + width: 100%; + overflow-x: auto; + white-space: nowrap; +} + +.carousel-slider div.thumbs div { + display: inline-block; + opacity: 0.5; +} + +.carousel-slider div.thumbs div button { + padding: 0; + height: 40px; + width: 40px; + margin-right: 2px; + border: 0; + cursor: pointer; + background-position: center; + background-size: cover; +} + +.carousel-slider div.thumbs div.active { + opacity: 1; +} + +.carousel-slider-container { + margin: 1rem 1rem 2rem; +} + +@media (min-width: 900px) { + .carousel-slider .btns, + .carousel-slider div.thumbs { + display: none; + } + .carousel-slider .arrows { + display: inline; + } +} \ No newline at end of file diff --git a/blocks/carousel-slider/carousel-slider.js b/blocks/carousel-slider/carousel-slider.js new file mode 100644 index 00000000..cac0100e --- /dev/null +++ b/blocks/carousel-slider/carousel-slider.js @@ -0,0 +1,307 @@ +import { getEnvelope } from '../../scripts/apis/creg/creg.js'; +import { button, div, img } from '../../scripts/dom-helpers.js'; + +async function getPropertyByPropId(propId) { + const resp = await getEnvelope(propId); + return resp; +} + + +const SLIDE_ID_PREFIX = 'slide'; +const SLIDE_CONTROL_ID_PREFIX = 'carousel-slide-control'; + +let curSlide = 0; +let maxSlide = 0; +let autoScroll; +let scrollInterval; +let scrollDuration = '1000'; + +/** + * Synchronizes the active thumbnail with the active slide in the carousel. + * @param {HTMLElement} carousel - The carousel element. + * @param {number} activeSlide - The index of the active slide. + */ +function syncActiveThumb(carousel, activeSlide) { + carousel.querySelectorAll('div.thumbs div').forEach((item, index) => { + const btn = item.querySelector('button'); + if (index === activeSlide) { + item.classList.add('active'); + btn.setAttribute('aria-selected', 'true'); + btn.setAttribute('tabindex', '0'); + } else { + item.classList.remove('active'); + btn.removeAttribute('aria-selected'); + btn.setAttribute('tabindex', '-1'); + } + }); +} + +/** + * Scrolls the carousel to the specified slide index and updates the active thumbnail. + * @param {HTMLElement} carousel - The carousel element. + * @param {number} [slideIndex=0] - The index of the slide to scroll to. + */ +function scrollToSlide(carousel, slideIndex = 0) { + const slideWidth = carousel.querySelector('.slide').offsetWidth + 5; + const carouselSlider = carousel.querySelector('.slide-container'); + carouselSlider.style.transform = `translateX(${-curSlide * slideWidth}px)`; + const thumbSlider = carousel.querySelector('.thumbs'); + if (curSlide > 1) thumbSlider.scrollTo({ left: thumbSlider.querySelector('div').offsetWidth * (curSlide - 1), behavior: 'smooth' }); + + syncActiveThumb(carousel, curSlide); + // sync slide + [...carouselSlider.children].forEach((slide, index) => { + if (index === slideIndex) { + slide.removeAttribute('tabindex'); + slide.setAttribute('aria-hidden', 'false'); + } else { + slide.setAttribute('tabindex', '-1'); + slide.setAttribute('aria-hidden', 'true'); + } + }); + curSlide = slideIndex; +} + +/** + * start auto scroll + */ +function startAutoScroll(block) { + if (!scrollInterval) { + scrollInterval = setInterval(() => { + scrollToSlide(block, curSlide < maxSlide ? curSlide + 1 : 0); + }, scrollDuration); + } +} + +/** + * stop auto scroll + */ +function stopAutoScroll() { + clearInterval(scrollInterval); + scrollInterval = undefined; +} + +/** + * Scrolls the element to the nearest block based on the scroll direction. + * + * @param {HTMLElement} el - The element to be scrolled. + * @param {number} [dir=1] - The scroll direction. Positive value for right, negative value for left. + */ +function snapScroll(el, dir = 1) { + if (!el) { + return; + } + let threshold = el.offsetWidth * 0.5; + if (dir >= 0) { + threshold -= (threshold * 0.5); + } else { + threshold += (threshold * 0.5); + } + const block = Math.floor(el.scrollLeft / el.offsetWidth); + const pos = el.scrollLeft - (el.offsetWidth * block); + const snapToBlock = pos <= threshold ? block : block + 1; + const carousel = el.closest('.carousel-slider'); + scrollToSlide(carousel, snapToBlock); +} + +/** + * Builds a navigation arrow element for the carousel slider. + * + * @param {string} dir - The direction of the arrow ('prev' or 'next'). + * @returns {HTMLElement} - The navigation arrow element. + */ +function buildNav(dir) { + const arrow = div({ + class: `btn-${dir}`, + 'aria-label': dir === 'prev' ? 'Previous Image' : 'Next Image', + role: 'button', + tabindex: dir === 'prev' ? '0' : '-1', + }); + arrow.addEventListener('click', (e) => { + let nextSlide = 0; + if (dir === 'prev') { + nextSlide = curSlide === 0 ? maxSlide : curSlide - 1; + } else { + nextSlide = curSlide === maxSlide ? 0 : curSlide + 1; + } + const carousel = e.target.closest('.carousel-slider'); + scrollToSlide(carousel, nextSlide); + }); + return arrow; +} + +/** + * Builds the thumbnail elements for the carousel slider. + * + * @param {Array} slides - An array of slide objects. + * @returns {HTMLElement} - The thumbnails container element. + */ +function buildThumbnails(slides = []) { + const thumbnails = div({ class: 'thumbs', role: 'tablist', style: `width: ${Math.round(window.innerWidth * 0.9)}px` }); + slides.forEach((slide, index) => { + const thumb = div({ + role: 'presentation', + class: index === 0 ? 'active' : '', + }); + const btn = button({ + type: 'button', + role: 'tab', + 'aria-controls': `${SLIDE_ID_PREFIX}${index}`, + 'aria-selected': index === 0 ? 'true' : 'false', + tabindex: index === 0 ? '0' : '-1', + 'aria-label': 'View Enlarged Image', + style: `background-image: url(${slide.mediaUrl})`, + }); + thumb.append(btn); + btn.addEventListener('click', (e) => { + curSlide = index; + const carousel = e.target.closest('.carousel-slider'); + scrollToSlide(carousel, curSlide); + }); + thumbnails.append(thumb); + }); + return thumbnails; +} + +/** + * Decorate a base slide element. + * + * @param item A base block slide element + * @param index The slide's position + * @return {HTMLUListElement} A decorated carousel slide element + */ +function buildSlide(item, index) { + const slide = div({ + class: 'slide', + id: `${SLIDE_ID_PREFIX}${index}`, + 'data-slide-index': index, + role: 'tabpanel', + 'aria-hidden': index === 0 ? 'false' : 'true', + 'aria-describedby': `${SLIDE_CONTROL_ID_PREFIX}${index}`, + tabindex: index === 0 ? '0' : '-1', + style: `width: ${Math.round(window.innerWidth * 0.9)}px`, + }, + img({ src: item.mediaUrl }), + div({ class: 'new-listing' }, 'New Listing'), + ); + return slide; +} + +/** + * Decorate and transform a carousel block. + * + * @param block HTML block from Franklin + */ +export default async function decorate(block) { + const propId = '370882966'; + + window.propertyData = await getPropertyByPropId(propId); + block.innerHTML = ''; + + const carousel = document.createElement('div'); + + carousel.classList.add('slide-container'); + + // if block contains class auto-scroll add scroll functionality and get interval + const blockClasses = block.className.split(' '); + const autoScrollClass = blockClasses.find((className) => className.startsWith('auto-scroll-')); + + if (autoScrollClass) { + autoScroll = true; + // get scroll duration + // eslint-disable-next-line prefer-destructuring + scrollDuration = autoScrollClass.match(/\d+/)[0]; + } + + // make carousel draggable + let isDown = false; + let startX = 0; + let startScroll = 0; + let prevScroll = 0; + + carousel.addEventListener('mouseenter', () => { + if (autoScroll) stopAutoScroll(); + }); + + carousel.addEventListener('mouseleave', () => { + if (isDown) { + snapScroll(carousel, carousel.scrollLeft > startScroll ? 1 : -1); + } + if (autoScroll) startAutoScroll(block); + isDown = false; + }); + + carousel.addEventListener('mousedown', (e) => { + isDown = true; + startX = e.pageX - carousel.offsetLeft; + startScroll = carousel.scrollLeft; + prevScroll = startScroll; + }); + + carousel.addEventListener('mouseup', () => { + if (isDown) { + snapScroll(carousel, carousel.scrollLeft > startScroll ? 1 : -1); + } + isDown = false; + }); + + carousel.addEventListener('mousemove', (e) => { + if (!isDown) { + return; + } + e.preventDefault(); + const x = e.pageX - carousel.offsetLeft; + const walk = (x - startX); + carousel.scrollLeft = prevScroll - walk; + }); + + // process each slide + const slides = [...window.propertyData.propertyDetails.smallPhotos]; + maxSlide = slides.length - 1; + slides.forEach((slide, index) => { + carousel.appendChild(buildSlide(slide, index)); + }); + + block.append(carousel); + + // add nav buttons and thumbs to block + if (slides.length > 1) { + const prevBtn = buildNav('prev'); + const nextBtn = buildNav('next'); + const btns = div({ class: 'btns', style: `width: ${Math.round(window.innerWidth * 0.9)}px` }, prevBtn, nextBtn); + const thumbs = buildThumbnails(slides); + block.append(btns, thumbs); + syncActiveThumb(block, 0); + } + + // autoscroll functionality + if (autoScroll) { + // auto scroll when visible + const intersectionOptions = { + root: null, + rootMargin: '0px', + threshold: 1.0, + }; + + const handleAutoScroll = (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + startAutoScroll(block); + } else { + stopAutoScroll(); + } + }); + }; + + const carouselObserver = new IntersectionObserver(handleAutoScroll, intersectionOptions); + carouselObserver.observe(block); + + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopAutoScroll(); + } else { + startAutoScroll(block); + } + }); + } +} diff --git a/blocks/property-carousel/property-carousel.css b/blocks/property-carousel/property-carousel.css deleted file mode 100755 index 2c47dfc7..00000000 --- a/blocks/property-carousel/property-carousel.css +++ /dev/null @@ -1,42 +0,0 @@ -/* */ -/* CSS: Responsive Styles for Image Carousel */ -.property-carousel .carousel { - position: relative; - max-width: 100%; - margin: auto; - overflow: hidden; -} - -.property-carousel .carousel-slide { - display: none; -} - -.property-carousel .carousel-slide img { - width: 100%; - height: auto; -} - -.property-carousel .prev, .property-carousel .next { - cursor: pointer; - position: absolute; - top: 50%; - width: auto; - padding: 16px; - margin-top: -22px; - color: white; - font-weight: bold; - font-size: 18px; - transition: 0.6s ease; - border-radius: 0 3px 3px 0; - user-select: none; -} - -.property-carousel .next { - right: 0; - border-radius: 3px 0 0 3px; -} - -/* Responsive Layout for screens less than 600px wide */ -@media screen and (min-width: 600px) { - -} diff --git a/blocks/property-carousel/property-carousel.js b/blocks/property-carousel/property-carousel.js deleted file mode 100755 index 8eb1115d..00000000 --- a/blocks/property-carousel/property-carousel.js +++ /dev/null @@ -1,42 +0,0 @@ -import { a, div, img } from '../../scripts/dom-helpers.js'; -import { getEnvelope } from '../../scripts/apis/creg/creg.js'; - -function builtCarousel(block) { - const galleryContent = div({ class: 'gallery-content' }, - div({ class: 'gallery' }), - ); - const galleryModal = div({ class: 'gallery-modal' }); - block.append(galleryContent, galleryModal); -} - -/** - * 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 getEnvelope(propId); - return resp; -} - -export default async function decorate(block) { - const propId = getPropIdFromPath(); - window.propertyData = await getPropertyByPropId(propId); - block.innerHTML = ''; - - // TODO: switch to use global propertyData - if (!window.propertyData) { - block.innerHTML = 'Property not found'; - } else { - const property = window.propertyData.propertyDetails; - builtCarousel(block); - } -} From 9a9740beb9eda6e0a164f84f5c52ea8ee210d16c Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Fri, 28 Jun 2024 12:24:19 -0600 Subject: [PATCH 09/13] add carousel to property details --- blocks/carousel-slider/carousel-slider.css | 2 +- blocks/carousel-slider/carousel-slider.js | 9 +++++---- blocks/property/property.js | 9 +++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/blocks/carousel-slider/carousel-slider.css b/blocks/carousel-slider/carousel-slider.css index 51a8a982..c89267f1 100644 --- a/blocks/carousel-slider/carousel-slider.css +++ b/blocks/carousel-slider/carousel-slider.css @@ -1,7 +1,7 @@ .carousel-slider { position: relative; width: 100%; - margin-top: 150px; + margin: 15px 0; overflow: hidden; } diff --git a/blocks/carousel-slider/carousel-slider.js b/blocks/carousel-slider/carousel-slider.js index cac0100e..bbb2c4db 100644 --- a/blocks/carousel-slider/carousel-slider.js +++ b/blocks/carousel-slider/carousel-slider.js @@ -6,7 +6,6 @@ async function getPropertyByPropId(propId) { return resp; } - const SLIDE_ID_PREFIX = 'slide'; const SLIDE_CONTROL_ID_PREFIX = 'carousel-slide-control'; @@ -193,9 +192,11 @@ function buildSlide(item, index) { * @param block HTML block from Franklin */ export default async function decorate(block) { - const propId = '370882966'; - - window.propertyData = await getPropertyByPropId(propId); + // TODO: remove this test propId + if (!window.propertyData) { + const propId = '358207023'; // commercial '368554873'; // '375215759'; // luxury '358207023'; + window.propertyData = await getPropertyByPropId(propId); + } block.innerHTML = ''; const carousel = document.createElement('div'); diff --git a/blocks/property/property.js b/blocks/property/property.js index 15d7f020..1c936ea8 100644 --- a/blocks/property/property.js +++ b/blocks/property/property.js @@ -1,4 +1,4 @@ -import { decorateIcons, loadBlock } from '../../scripts/aem.js'; +import { buildBlock, decorateBlock, decorateIcons, loadBlock } from '../../scripts/aem.js'; import { getEnvelope } from '../../scripts/apis/creg/creg.js'; import { a, button, div, domEl, img, p, span, @@ -40,7 +40,6 @@ async function getPropertyByPropId(propId) { } 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 = '358207023'; // commercial '368554873'; // '375215759'; // luxury '358207023'; @@ -106,8 +105,10 @@ export default async function decorate(block) { decorateIcons(block); // Load the carousel slider block - /* const carousel = await loadBlock('carousel-slider'); - block.append(carousel); */ + const carousel = buildBlock('carousel-slider', ''); + block.append(carousel); + decorateBlock(carousel); + loadBlock(carousel); // create contact info const description = div({ class: 'details-description' }, From f43aa712227e235ed932e2c0005afc5ff35f0cf3 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Tue, 9 Jul 2024 15:00:29 -0600 Subject: [PATCH 10/13] add services for schools and trends --- scripts/apis/creg/creg.js | 40 +++++++++++++++++++++++ scripts/apis/creg/workers/markettrends.js | 24 ++++++++++++++ scripts/apis/creg/workers/schools.js | 20 ++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 scripts/apis/creg/workers/markettrends.js create mode 100644 scripts/apis/creg/workers/schools.js diff --git a/scripts/apis/creg/creg.js b/scripts/apis/creg/creg.js index 4e42cceb..1fd1fedb 100644 --- a/scripts/apis/creg/creg.js +++ b/scripts/apis/creg/creg.js @@ -94,3 +94,43 @@ export async function getEnvelope(listingId) { }); }); } + +/** + * Gets the school details for the specified listing. + * + * @param {string} lat latitude + * @param {string} long longitude + * @return {Promise} resolving the economic details + */ +export async function getSchools(lat, long) { + return new Promise((resolve) => { + const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/schools.js`, { type: 'module' }); + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ + lat, + long, + }); + }); +} + +/** + * Gets the market trends for . + * + * @param {string} listingId - The ID of the listing. + * @param {string} lat latitude + * @param {string} long longitude + * @param {string} zipcode the zip code + * @return {Promise} resolving the economic details + */ +export async function getMarketTrends(listingId, lat, long, zipcode) { + return new Promise((resolve) => { + const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/markettrends.js`, { type: 'module' }); + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ + listingId, + lat, + long, + zipcode, + }); + }); +} diff --git a/scripts/apis/creg/workers/markettrends.js b/scripts/apis/creg/workers/markettrends.js new file mode 100644 index 00000000..6ade431c --- /dev/null +++ b/scripts/apis/creg/workers/markettrends.js @@ -0,0 +1,24 @@ +/** + * Handle the Worker event. Fetches school 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.id - The ID of the listing. + * @param {string} event.data.lat latitude + * @param {string} event.data.long longitude + * @param {string} event.data.zipcode the zip code + */ +onmessage = async (event) => { + const { + id, lat, long, zip, + } = event.data; + const promises = []; + promises.push( + fetch(`/bin/bhhs/pdp/cregSchoolServlet?PropertyId=${id}&Latitude=${lat}&Longitude=${long}&zipCode=${zip}`) + .then((resp) => (resp.ok ? resp.json() : undefined)), + ); + + Promise.all(promises).then((values) => { + postMessage(values.filter((v) => v)); + }); +}; diff --git a/scripts/apis/creg/workers/schools.js b/scripts/apis/creg/workers/schools.js new file mode 100644 index 00000000..80c47e3a --- /dev/null +++ b/scripts/apis/creg/workers/schools.js @@ -0,0 +1,20 @@ +/** + * Handle the Worker event. Fetches school 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 { lat, long } = event.data; + const promises = []; + promises.push( + fetch(`/bin/bhhs/pdp/cregSchoolServlet?latitude=${lat}&longitude=${long}`) + .then((resp) => (resp.ok ? resp.json() : undefined)), + ); + + Promise.all(promises).then((values) => { + postMessage(values.filter((v) => v)); + }); +}; From 8cbb9a413e0149a33e5b928627555df76ccbd1e7 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Fri, 12 Jul 2024 14:59:06 -0600 Subject: [PATCH 11/13] open houses and market --- blocks/carousel-slider/carousel-slider.js | 6 +- blocks/economic-data/economic-data.js | 13 +-- .../property-attributes.css | 2 + .../property-attributes.js | 33 +++--- blocks/property-data/property-data.css | 0 blocks/property-data/property-data.js | 103 ++++++++++++++++++ blocks/property/property.css | 79 ++++++++++++++ blocks/property/property.js | 53 +++++---- scripts/apis/creg/workers/markettrends.js | 6 +- scripts/util.js | 28 +++++ 10 files changed, 265 insertions(+), 58 deletions(-) create mode 100644 blocks/property-data/property-data.css create mode 100644 blocks/property-data/property-data.js diff --git a/blocks/carousel-slider/carousel-slider.js b/blocks/carousel-slider/carousel-slider.js index bbb2c4db..029eb1e7 100644 --- a/blocks/carousel-slider/carousel-slider.js +++ b/blocks/carousel-slider/carousel-slider.js @@ -193,9 +193,9 @@ function buildSlide(item, index) { */ export default async function decorate(block) { // TODO: remove this test propId - if (!window.propertyData) { + if (!window.envelope) { const propId = '358207023'; // commercial '368554873'; // '375215759'; // luxury '358207023'; - window.propertyData = await getPropertyByPropId(propId); + window.envelope = await getPropertyByPropId(propId); } block.innerHTML = ''; @@ -257,7 +257,7 @@ export default async function decorate(block) { }); // process each slide - const slides = [...window.propertyData.propertyDetails.smallPhotos]; + const slides = [...window.envelope.propertyDetails.smallPhotos]; maxSlide = slides.length - 1; slides.forEach((slide, index) => { carousel.appendChild(buildSlide(slide, index)); diff --git a/blocks/economic-data/economic-data.js b/blocks/economic-data/economic-data.js index 7b528b41..505c8cd0 100755 --- a/blocks/economic-data/economic-data.js +++ b/blocks/economic-data/economic-data.js @@ -1,6 +1,7 @@ import { getDetails, getEconomicDetails } from '../../scripts/apis/creg/creg.js'; import { div, span } from '../../scripts/dom-helpers.js'; import { decorateIcons } from '../../scripts/aem.js'; +import { toggleAccordion } from '../../scripts/util.js'; const keys = [ 'ListPriceUS', @@ -24,11 +25,6 @@ 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. @@ -42,11 +38,6 @@ function getPropIdFromPath() { 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]; @@ -157,7 +148,7 @@ export default async function decorate(block) { // TODO: remove this test propId if (!propId) propId = '370882966'; - const propertyData = await getPropertyByPropId(propId); + const propertyData = await getDetails(propId); if (propertyData) { property = pick(propertyData, ...keys); if (property.Latitude && property.Longitude) { diff --git a/blocks/property-attributes/property-attributes.css b/blocks/property-attributes/property-attributes.css index 8ced4175..314810db 100755 --- a/blocks/property-attributes/property-attributes.css +++ b/blocks/property-attributes/property-attributes.css @@ -112,6 +112,8 @@ } div.idxDisclaimer { + max-width: var(--normal-page-width); + margin: 0 auto 3.5em; font-size: var(--body-font-size-xxs); line-height: var(--line-height-s); color: var(--dark-grey); diff --git a/blocks/property-attributes/property-attributes.js b/blocks/property-attributes/property-attributes.js index 128baa34..8d606053 100755 --- a/blocks/property-attributes/property-attributes.js +++ b/blocks/property-attributes/property-attributes.js @@ -2,16 +2,16 @@ import { div, domEl, span, } from '../../scripts/dom-helpers.js'; import { - formatNumber, phoneFormat, formatCurrency, + formatNumber, phoneFormat, formatCurrency, toggleAccordion, } from '../../scripts/util.js'; -import { decorateIcons } from '../../scripts/aem.js'; - -function toggleAccordion(event) { - const content = event.target; - content.classList.toggle('active'); -} +import { + decorateIcons, +} from '../../scripts/aem.js'; export function formatListToHTML(str) { + if (!str) { + return ''; + } const strParts = str.split(',').map((part) => part.trim()); const strElements = []; @@ -28,10 +28,10 @@ export function formatListToHTML(str) { export default async function decorate(block) { block.innerHTML = ''; - if (!window.propertyData) { + if (!window.envelope) { block.innerHTML = 'Property not found'; } else { - const property = window.propertyData.propertyDetails; + const property = window.envelope.propertyDetails; const lotSF = property.lotSizeSquareFeet ? `${formatNumber(property.lotSizeSquareFeet)} ${property.interiorFeatures.livingAreaUnits}` : ''; const title = div({ class: 'title' }, 'Property Details'); @@ -99,7 +99,7 @@ export default async function decorate(block) { div({ class: 'accordion-content' }, div({ class: 'table' }, div({ class: 'label' }, 'Lot/Land Description'), - div({ class: 'td' }, property.interiorFeatures.description || 0), + div({ class: 'td' }, formatListToHTML(property.interiorFeatures.description)), div({ class: 'label' }, 'Foundation'), div({ class: 'td' }, property.interiorFeatures.foundation), div({ class: 'label' }, 'Parking Spaces'), @@ -128,27 +128,22 @@ export default async function decorate(block) { div({ class: 'label' }, 'Price Per Sq Ft'), div({ class: 'td' }, formatCurrency(property.utilityAndBuilding.pricePerSqFt)), div({ class: 'label' }, 'Architectural Style'), - div({ class: 'td' }, property.utilityAndBuilding.architecturalStyle), + div({ class: 'td' }, formatListToHTML(property.utilityAndBuilding.architecturalStyle)), ), ), ), ); + // disclaimer const disclaimer = div({ class: 'idxDisclaimer' }, domEl('hr'), property.idxDisclaimer, ); block.append(title, details, features); - block.parentNode.parentNode.insertAdjacentElement('afterEnd', disclaimer); + const section = document.querySelector('.property-attributes-container'); + section.insertAdjacentElement('afterend', disclaimer); } decorateIcons(block); - // disclaimer - // market trends - // calculator - // schools - // Occupancy - // housing trends - // load economic data block } diff --git a/blocks/property-data/property-data.css b/blocks/property-data/property-data.css new file mode 100644 index 00000000..e69de29b diff --git a/blocks/property-data/property-data.js b/blocks/property-data/property-data.js new file mode 100644 index 00000000..d6c6eb66 --- /dev/null +++ b/blocks/property-data/property-data.js @@ -0,0 +1,103 @@ +import { getMarketTrends } from '../../scripts/apis/creg/creg.js'; +import { div } from '../../scripts/dom-helpers.js'; +import { + formatCurrency, +} from '../../scripts/util.js'; + +function daysOnMarket(listingContractDate) { + const listingDate = new Date(listingContractDate); + const currentDate = new Date(); + const timeDifference = currentDate - listingDate; + const daysDifference = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); + return daysDifference; +} + +export default async function decorate(block) { + block.innerHTML = ''; + let property; + let marketdata; + + if (window.envelope.propertyDetails) { + property = window.envelope.propertyDetails; + const data = await getMarketTrends( + property.PropId, + property.Latitude, + property.Longitude, + property.PostalCode, + ); + if (data) { + [marketdata] = data; + } + } + + const trends = div({ class: 'accordions' }, + div({ class: 'accordion' }, + div({ class: 'accordion-header', onclick: (e) => toggleAccordion(e) }, 'Market Trends'), + div({ class: 'accordion-content' }, + div({ class: 'row' }, + div({ class: 'address' }, property.unstructuredAddress), + div({ class: 'zip' }, `ZIP Code: ${property.postalCode}`), + ), + div({ class: 'row' }, + div({ class: 'td' }, + div({ class: 'label' }, 'List Price'), + div({ class: 'value' }, formatCurrency(property.listPrice)), + ), + div({ class: 'td' }, + div({ class: 'label' }, 'Median List Price'), + div({ class: 'value' }, marketdata.total.medianListPrice.split(' ')[0]), + div({ class: 'chart' }), + ), + ), + div({ class: 'row' }, + div({ class: 'td' }, + div({ class: 'label' }, 'Sale Price'), + div({ class: 'value' }, property.closePrice || '-'), + ), + div({ class: 'td' }, + div({ class: 'label' }, 'Median Sold Price'), + div({ class: 'value' }, marketdata.total.medianSalesPrice.split(' ')[0]), + div({ class: 'chart' }), + ), + ), + div({ class: 'row' }, + div({ class: 'td' }, + div({ class: 'label' }, 'Price/SQFT'), + div({ class: 'value' }, formatCurrency(property.utilityAndBuilding.pricePerSqFt)), + ), + div({ class: 'td' }, + div({ class: 'label' }, 'AVG Price/SQFT'), + div({ class: 'value' }, marketdata.total.avgPriceArea.split(' ')[0]), + div({ class: 'chart' }), + ), + ), + div({ class: 'row' }, + div({ class: 'td' }, + div({ class: 'label' }, 'Days on Market'), + div({ class: 'value' }, property.closePrice ? 0 : daysOnMarket(property.listingContractDate)), + ), + div({ class: 'td' }, + div({ class: 'label' }, 'AVG Days on Market'), + div({ class: 'value' }, marketdata.total.avgDaysOnMarket), + div({ class: 'chart' }), + ), + ), + div({ class: 'row' }, + div({ class: 'label' }, 'Homes for Sale'), + div({ class: 'label' }, 'Homes Sold'), + ), + div({ class: 'row' }, + div({ class: 'td' }, + div({ class: 'value' }, marketdata.total.homesForSale), + div({ class: 'chart' }), + ), + div({ class: 'td' }, + div({ class: 'value' }, marketdata.total.homesSold), + div({ class: 'chart' }), + ), + ), + ), + ), + ); + block.append(trends); +} diff --git a/blocks/property/property.css b/blocks/property/property.css index 46d9a6f4..0e3e3e06 100644 --- a/blocks/property/property.css +++ b/blocks/property/property.css @@ -73,6 +73,67 @@ color: var(--dark-grey); } +.property.block .property-details .open-house { + display: flex; + flex-wrap: wrap; + margin: 1rem -15px 0; + padding: 0 15px; + font-family: var(--font-family-primary); +} + +.property.block .property-details .row { + flex: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + font-size: var(--body-font-size-xxs); + color: #2a2223; + font-weight: 700; + line-height: 30px; +} + +.property.block .property-details .icon-wrapper, +.property.block .property-details .meta-wrapper { + display: flex; + flex-direction: column; + margin: 0; +} + +.property.block .property-details .icon-wrapper { + position: relative; + padding: 0 8px 0 32px; + border: 1px solid #2a2223; + line-height: 30px; + width: 115px; + height: 32px; +} + +.property.block .property-details .icon-wrapper span { + position: absolute; + top: 2px; + left: 4px; + width: 24px; + height: 24px; +} + +.property.block .property-details .icon-wrapper .label { + text-transform: capitalize; + font-weight: 400; +} + +.property.block .property-details .datetime { + display: flex; +} + +.property.block .property-details .datetime .date, +.property.block .property-details .datetime .time { + font-weight: 700; +} + +.property.block .property-details .datetime .time { + padding-left: 8px; +} + .property.block .property-details > .property-price { order: 3; font-family: var(--font-family-primary); @@ -265,6 +326,24 @@ max-width: 47%; } + .property.block .property-details .row { + flex-direction: row; + } + + .property.block .property-details .meta-wrapper { + flex-direction: row; + } + + .property.block .property-details .datetime { + margin-top: 0; + padding: 0 4px; + } + + .property.block .property-details .datetime .date, + .property.block .property-details .datetime .time { + display: inline; + } + .property.block .row { display: flex; flex-wrap: wrap; diff --git a/blocks/property/property.js b/blocks/property/property.js index 1c936ea8..02f70e0f 100644 --- a/blocks/property/property.js +++ b/blocks/property/property.js @@ -1,9 +1,11 @@ import { buildBlock, decorateBlock, decorateIcons, loadBlock } from '../../scripts/aem.js'; -import { getEnvelope } from '../../scripts/apis/creg/creg.js'; +import { getDetails, getEnvelope } from '../../scripts/apis/creg/creg.js'; import { a, button, div, domEl, img, p, span, } from '../../scripts/dom-helpers.js'; -import { formatCurrency, formatNumber, getImageURL } from '../../scripts/util.js'; +import { + formatCurrency, formatNumber, formatDate, getImageURL, to12HourTime, +} from '../../scripts/util.js'; function toggleHeight() { const content = document.getElementById('remark-content'); @@ -34,26 +36,23 @@ function getPropIdFromPath() { return null; } -async function getPropertyByPropId(propId) { - const resp = await getEnvelope(propId); - return resp; -} - export default async function decorate(block) { let propId = getPropIdFromPath(); // assumes the listing page pathname ends with the propId // TODO: remove this test propId - if (!propId) propId = '358207023'; // commercial '368554873'; // '375215759'; // luxury '358207023'; + if (!propId) propId = '376611673'; // commercial '368554873'; // '375215759'; // luxury '358207023'; - window.propertyData = await getPropertyByPropId(propId); + window.envelope = await getEnvelope(propId); + window.listing = await getDetails(propId); block.innerHTML = ''; - if (!window.propertyData) { + if (!window.envelope) { block.innerHTML = 'Property not found'; } else { - const property = window.propertyData.propertyDetails; + const property = window.envelope.propertyDetails; + const [details] = window.listing; const propertyPrice = formatCurrency(property.listPrice); - const propertyAddress = window.propertyData.addressLine1; - const propertyAddress2 = window.propertyData.addressLine2; + const propertyAddress = window.envelope.addressLine1; + const propertyAddress2 = window.envelope.addressLine2; const rooms = property.bedroomsTotal + property.interiorFeatures.bathroomsTotal; const bedBath = property.bedroomsTotal ? `${property.bedroomsTotal} bed / ${property.interiorFeatures.bathroomsTotal} bath` : ''; const livingSpace = property.livingArea ? `${formatNumber(property.livingArea)} ${property.interiorFeatures.livingAreaUnits}` : ''; @@ -71,6 +70,15 @@ export default async function decorate(block) { div({ class: 'property-address' }, propertyAddress, document.createElement('br'), propertyAddress2), div({ class: 'property-specs' }, propertySpecs), div({ class: 'courtesy' }, propertyCourtesyOf), + details.OpenHouses.length ? div({ class: 'open-house' }, + div({ class: 'row' }, + div({ class: 'icon-wrapper' }, + span({ class: 'icon icon-openhouse' }), + div({ class: 'label' }, 'Open House'), + ), + div({ class: 'meta-wrapper' }), + ), + ) : '', ), div({ class: 'property-price' }, propertyPrice), div({ class: 'save-share' }, @@ -90,6 +98,16 @@ export default async function decorate(block) { ); block.append(propertyDetails); + if (details.OpenHouses.length) { + details.OpenHouses.forEach((openHouse) => { + const temp = div({ class: 'datetime' }, + div({ class: 'date' }, formatDate(openHouse.OpenHouseDate)), + div({ class: 'time' }, to12HourTime(openHouse.OpenHouseStartTime), ' to ', to12HourTime(openHouse.OpenHouseEndTime)), + ); + block.querySelector('.open-house .meta-wrapper').append(temp); + }); + } + if (property.isLuxury) { const luxury = div({ class: 'luxury' }, img({ src: '/icons/lux_mark_classic_blk.svg', alt: 'Luxury Property' }), @@ -169,14 +187,5 @@ export default async function decorate(block) { phone.textContent = agent.telephone; phone.href = `tel:${agent.telephone}`; } - - // new section with property details - // disclaimer - // market trends - // calculator - // schools - // Occupancy - // housing trends - // load economic data block } } diff --git a/scripts/apis/creg/workers/markettrends.js b/scripts/apis/creg/workers/markettrends.js index 6ade431c..d8f0493a 100644 --- a/scripts/apis/creg/workers/markettrends.js +++ b/scripts/apis/creg/workers/markettrends.js @@ -3,18 +3,18 @@ * * @param {Object} event the worker event. * @param {string} event.data.api the URL to fetch. - * @param {string} event.data.id - The ID of the listing. + * @param {string} event.data.listingId - The ID of the listing. * @param {string} event.data.lat latitude * @param {string} event.data.long longitude * @param {string} event.data.zipcode the zip code */ onmessage = async (event) => { const { - id, lat, long, zip, + listingId, lat, long, zipcode, } = event.data; const promises = []; promises.push( - fetch(`/bin/bhhs/pdp/cregSchoolServlet?PropertyId=${id}&Latitude=${lat}&Longitude=${long}&zipCode=${zip}`) + fetch(`/bin/bhhs/CregMarketTrends?PropertyId=${listingId}&Latitude=${lat}&Longitude=${long}&zipCode=${zipcode}`) .then((resp) => (resp.ok ? resp.json() : undefined)), ); diff --git a/scripts/util.js b/scripts/util.js index 99d5038b..f99cd56a 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -219,6 +219,34 @@ export function formatNumber(num, precision = 0) { return formatter.format(num); } +export function formatDate(dateString) { + const date = new Date(dateString); + // Array of weekday names + const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + // Get the weekday name, day, and month + const weekdayName = weekdays[date.getDay()]; + const day = date.getDate(); + const month = date.getMonth() + 1; + // Return the formatted date string + return `${weekdayName} ${month}/${day}`; +} + +export function to12HourTime(timeString) { + const [hours, minutes] = timeString.split(':'); + let period = 'AM'; + let hour = hours; + if (hours > 12) { + hour = hours - 12; + period = 'PM'; + } + return `${hour}:${minutes} ${period}`; +} + +export function toggleAccordion(event) { + const content = event.target; + content.classList.toggle('active'); +} + const Util = { getSpinner, showModal, From 3f9add5d4b3da6a3136fbaa39eb1e8d8bef3fe2c Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Mon, 15 Jul 2024 08:08:18 -0600 Subject: [PATCH 12/13] formating trends --- blocks/property-data/property-data.css | 54 ++++++++++++++++++++++++++ blocks/property-data/property-data.js | 6 +-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/blocks/property-data/property-data.css b/blocks/property-data/property-data.css index e69de29b..627cf1a2 100644 --- a/blocks/property-data/property-data.css +++ b/blocks/property-data/property-data.css @@ -0,0 +1,54 @@ +.section.property-data-container { + width: var(--full-page-width); + background-color: var(--tertiary-color); +} + +.property-data.block .accordion-header { + cursor: pointer; + padding: 16px 30px 16px 0; + position: relative; + display: inline-block; + font-family: var(--font-family-primary); + font-weight: var(--font-weight-semibold); + line-height: 26px; + margin: 0 5px 0 0; + font-size: 22px; + width: 100%; + color: var(--primary-color); +} + +.property-data.block .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); +} + +.property-data.block .accordion .accordion-header:not(.active)::after { + transform: rotate(90deg); + transition: transform .3s linear; +} + +.property-data.block .accordion-content { + display: none; + padding-bottom: 60px; +} + +.property-data.block .accordion-header.active + .accordion-content { + display: block; +} + +.property-data.block .accordion-content .row .head { + font-size: var(--heading-font-size-m); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-s); + color: var(--body-color); + letter-spacing: var(--letter-spacing-xs); + margin-bottom: 10px; +} \ No newline at end of file diff --git a/blocks/property-data/property-data.js b/blocks/property-data/property-data.js index d6c6eb66..194e8c1c 100644 --- a/blocks/property-data/property-data.js +++ b/blocks/property-data/property-data.js @@ -1,7 +1,7 @@ import { getMarketTrends } from '../../scripts/apis/creg/creg.js'; import { div } from '../../scripts/dom-helpers.js'; import { - formatCurrency, + formatCurrency, toggleAccordion, } from '../../scripts/util.js'; function daysOnMarket(listingContractDate) { @@ -35,8 +35,8 @@ export default async function decorate(block) { div({ class: 'accordion-header', onclick: (e) => toggleAccordion(e) }, 'Market Trends'), div({ class: 'accordion-content' }, div({ class: 'row' }, - div({ class: 'address' }, property.unstructuredAddress), - div({ class: 'zip' }, `ZIP Code: ${property.postalCode}`), + div({ class: 'head' }, property.unstructuredAddress), + div({ class: 'head' }, `ZIP Code: ${property.postalCode}`), ), div({ class: 'row' }, div({ class: 'td' }, From 57adee34cc497c859ed3f0c497f776b385b62cb9 Mon Sep 17 00:00:00 2001 From: Rob Rusher Date: Tue, 16 Jul 2024 08:29:01 -0600 Subject: [PATCH 13/13] add price history api worker --- scripts/apis/creg/creg.js | 18 +++++++++++++++++- scripts/apis/creg/workers/pricehistory.js | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 scripts/apis/creg/workers/pricehistory.js diff --git a/scripts/apis/creg/creg.js b/scripts/apis/creg/creg.js index 1fd1fedb..c2ba8314 100644 --- a/scripts/apis/creg/creg.js +++ b/scripts/apis/creg/creg.js @@ -114,7 +114,7 @@ export async function getSchools(lat, long) { } /** - * Gets the market trends for . + * Gets the market trends for a listing. * * @param {string} listingId - The ID of the listing. * @param {string} lat latitude @@ -134,3 +134,19 @@ export async function getMarketTrends(listingId, lat, long, zipcode) { }); }); } + +/** + * Gets the price history for a listing. + * + * @param {string} listingId - The ID of the listing. + * @return {Promise} resolving the economic details + */ +export async function getPriceHistory(listingId) { + return new Promise((resolve) => { + const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/pricehistory.js`, { type: 'module' }); + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ + listingId, + }); + }); +} diff --git a/scripts/apis/creg/workers/pricehistory.js b/scripts/apis/creg/workers/pricehistory.js new file mode 100644 index 00000000..43225514 --- /dev/null +++ b/scripts/apis/creg/workers/pricehistory.js @@ -0,0 +1,19 @@ +/** + * Handle the Worker event. Fetches price history for a provided listing id. + * + * @param {Object} event the worker event. + * @param {string} event.data.api the URL to fetch. + * @param {string} event.data.id listing id. + */ +onmessage = async (event) => { + const { listingId } = event.data; + + try { + const response = await fetch(`/v1/pricehistory/${listingId}`); + const data = response.ok ? await response.json() : undefined; + + postMessage(data); + } catch (error) { + postMessage({}); + } +};