diff --git a/blocks/carousel-slider/carousel-slider.css b/blocks/carousel-slider/carousel-slider.css new file mode 100644 index 00000000..c89267f1 --- /dev/null +++ b/blocks/carousel-slider/carousel-slider.css @@ -0,0 +1,113 @@ +.carousel-slider { + position: relative; + width: 100%; + margin: 15px 0; + 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..029eb1e7 --- /dev/null +++ b/blocks/carousel-slider/carousel-slider.js @@ -0,0 +1,308 @@ +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) { + // TODO: remove this test propId + if (!window.envelope) { + const propId = '358207023'; // commercial '368554873'; // '375215759'; // luxury '358207023'; + window.envelope = 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.envelope.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/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 new file mode 100755 index 00000000..314810db --- /dev/null +++ b/blocks/property-attributes/property-attributes.css @@ -0,0 +1,145 @@ +.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 { + 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); + 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..8d606053 --- /dev/null +++ b/blocks/property-attributes/property-attributes.js @@ -0,0 +1,149 @@ +import { + div, domEl, span, +} from '../../scripts/dom-helpers.js'; +import { + formatNumber, phoneFormat, formatCurrency, toggleAccordion, +} from '../../scripts/util.js'; +import { + decorateIcons, +} from '../../scripts/aem.js'; + +export function formatListToHTML(str) { + if (!str) { + return ''; + } + 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.envelope) { + block.innerHTML = 'Property not found'; + } else { + const property = window.envelope.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' }, formatListToHTML(property.interiorFeatures.description)), + 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' }, formatListToHTML(property.utilityAndBuilding.architecturalStyle)), + ), + ), + ), + ); + + // disclaimer + const disclaimer = div({ class: 'idxDisclaimer' }, + domEl('hr'), + property.idxDisclaimer, + ); + + block.append(title, details, features); + const section = document.querySelector('.property-attributes-container'); + section.insertAdjacentElement('afterend', disclaimer); + } + + decorateIcons(block); +} diff --git a/blocks/property-data/property-data.css b/blocks/property-data/property-data.css new file mode 100644 index 00000000..627cf1a2 --- /dev/null +++ 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 new file mode 100644 index 00000000..194e8c1c --- /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, toggleAccordion, +} 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: 'head' }, property.unstructuredAddress), + div({ class: 'head' }, `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 new file mode 100644 index 00000000..0e3e3e06 --- /dev/null +++ b/blocks/property/property.css @@ -0,0 +1,372 @@ +.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; + padding: 15px 0 20px; +} + +.property.block .property-details .backnav { + flex: 0 0 100%; + max-width: 100%; +} + +.property.block .property-details .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-info .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-info .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 .property-info .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-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); + 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); + margin-top: 1rem; +} + +.property.block .button-container { + margin-bottom: 0; +} + +.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 .property-details .button-container .icon-heartempty, +.property.block .property-details .button-container .icon-share-empty { + margin-right: 8px; +} + +.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; +} + +.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%; + 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%; + } + + .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; + 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 new file mode 100644 index 00000000..02f70e0f --- /dev/null +++ b/blocks/property/property.js @@ -0,0 +1,191 @@ +import { buildBlock, decorateBlock, decorateIcons, loadBlock } from '../../scripts/aem.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, formatDate, getImageURL, to12HourTime, +} 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. + * @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 propId = getPropIdFromPath(); // assumes the listing page pathname ends with the propId + // TODO: remove this test propId + if (!propId) propId = '376611673'; // commercial '368554873'; // '375215759'; // luxury '358207023'; + + window.envelope = await getEnvelope(propId); + window.listing = await getDetails(propId); + block.innerHTML = ''; + + if (!window.envelope) { + block.innerHTML = 'Property not found'; + } else { + const property = window.envelope.propertyDetails; + const [details] = window.listing; + const propertyPrice = formatCurrency(property.listPrice); + 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}` : ''; + 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 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), + 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' }, + 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(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' }), + ); + propertyDetails.prepend(luxury); + } + const nav = div({ class: 'backnav' }, + div({ class: 'back' }, + a({ href: '#' }, 'Back'), + ), + ); + propertyDetails.prepend(nav); + decorateIcons(block); + + // Load the carousel slider block + const carousel = buildBlock('carousel-slider', ''); + block.append(carousel); + decorateBlock(carousel); + loadBlock(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}`; + } + } +} 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/apis/creg/creg.js b/scripts/apis/creg/creg.js index 4e42cceb..c2ba8314 100644 --- a/scripts/apis/creg/creg.js +++ b/scripts/apis/creg/creg.js @@ -94,3 +94,59 @@ 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 a listing. + * + * @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, + }); + }); +} + +/** + * 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/markettrends.js b/scripts/apis/creg/workers/markettrends.js new file mode 100644 index 00000000..d8f0493a --- /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.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 { + listingId, lat, long, zipcode, + } = event.data; + const promises = []; + promises.push( + fetch(`/bin/bhhs/CregMarketTrends?PropertyId=${listingId}&Latitude=${lat}&Longitude=${long}&zipCode=${zipcode}`) + .then((resp) => (resp.ok ? resp.json() : undefined)), + ); + + Promise.all(promises).then((values) => { + postMessage(values.filter((v) => v)); + }); +}; 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({}); + } +}; 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)); + }); +}; diff --git a/scripts/util.js b/scripts/util.js index db887d1c..f99cd56a 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 @@ -189,6 +205,48 @@ 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, precision = 0) { + const formatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: precision }); + 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,